working API level 1
This commit is contained in:
parent
b214ddb6c9
commit
e3a0522e87
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/.gradle/
|
||||
/.idea/
|
||||
build
|
78
build.gradle.kts
Normal file
78
build.gradle.kts
Normal file
@ -0,0 +1,78 @@
|
||||
val ktor_version: String by project
|
||||
val kotlin_version: String by project
|
||||
|
||||
plugins {
|
||||
kotlin("multiplatform") version "1.7.10"
|
||||
kotlin("plugin.serialization") version "1.7.10"
|
||||
`maven-publish`
|
||||
|
||||
}
|
||||
|
||||
group = "net.sergeych"
|
||||
version = "0.0.1-SNAPSHOT"
|
||||
|
||||
repositories {
|
||||
maven("https://maven.universablockchain.com")
|
||||
mavenCentral()
|
||||
mavenLocal()
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm {
|
||||
compilations.all {
|
||||
kotlinOptions.jvmTarget = "1.8"
|
||||
}
|
||||
withJava()
|
||||
testRuns["test"].executionTask.configure {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
}
|
||||
js(IR) {
|
||||
browser {
|
||||
commonWebpackConfig {
|
||||
cssSupport.enabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// val hostOs = System.getProperty("os.name")
|
||||
// val isMingwX64 = hostOs.startsWith("Windows")
|
||||
// val nativeTarget = when {
|
||||
// hostOs == "Mac OS X" -> macosX64("native")
|
||||
// hostOs == "Linux" -> linuxX64("native")
|
||||
// isMingwX64 -> mingwX64("native")
|
||||
// else -> throw GradleException("Host OS is not supported in Kotlin/Native.")
|
||||
// }
|
||||
|
||||
|
||||
sourceSets {
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.3")
|
||||
// implementation("org.jetbrains.kotlin:atomicfu:1.6.21")
|
||||
implementation("io.ktor:ktor-client-core:$ktor_version")
|
||||
implementation("io.ktor:ktor-client-content-negotiation:$ktor_version")
|
||||
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
|
||||
api("net.sergeych:unikrypto:1.1.1-SNAPSHOT")
|
||||
api("org.jetbrains.kotlinx:kotlinx-datetime:0.3.2")
|
||||
api("net.sergeych:mp_stools:1.2.3-SNAPSHOT")
|
||||
api("net.sergeych:boss-serialization-mp:0.1.2-SNAPSHOT")
|
||||
}
|
||||
}
|
||||
val commonTest by getting {
|
||||
dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
}
|
||||
val jvmMain by getting {
|
||||
dependencies {
|
||||
implementation("io.ktor:ktor-client-cio:$ktor_version")
|
||||
}
|
||||
}
|
||||
val jvmTest by getting
|
||||
val jsMain by getting
|
||||
val jsTest by getting
|
||||
// val nativeMain by getting
|
||||
// val nativeTest by getting
|
||||
}
|
||||
}
|
6
gradle.properties
Normal file
6
gradle.properties
Normal file
@ -0,0 +1,6 @@
|
||||
kotlin.code.style=official
|
||||
kotlin.mpp.enableGranularSourceSetsMetadata=true
|
||||
kotlin.native.enableDependencyPropagation=false
|
||||
kotlin.js.generate.executable.default=false
|
||||
ktor_version=2.0.3
|
||||
kotlin_version=1.7.10
|
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
2232
kotlin-js-store/yarn.lock
Normal file
2232
kotlin-js-store/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
3
settings.gradle.kts
Normal file
3
settings.gradle.kts
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
rootProject.name = "crypstie3api"
|
||||
|
18
src/commonMain/kotlin/crypstie3/ApiCrypstie.kt
Normal file
18
src/commonMain/kotlin/crypstie3/ApiCrypstie.kt
Normal file
@ -0,0 +1,18 @@
|
||||
package crypstie3
|
||||
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
@Serializable
|
||||
data class ApiCrypstie(
|
||||
val guid: String,
|
||||
val encryptedData: ByteArray,
|
||||
val deleteAt: Instant = Clock.System.now() + 90.days,
|
||||
val burnOnShow: Boolean = false,
|
||||
val showWide: Boolean = false,
|
||||
val syntaxHighlighting: String? = null,
|
||||
val editable: Boolean = false,
|
||||
val ownerHandle: String? = null,
|
||||
)
|
167
src/commonMain/kotlin/crypstie3/Client.kt
Normal file
167
src/commonMain/kotlin/crypstie3/Client.kt
Normal file
@ -0,0 +1,167 @@
|
||||
@file:OptIn(ExperimentalSerializationApi::class)
|
||||
|
||||
package crypstie3
|
||||
|
||||
import api.ApiError
|
||||
import api.ErrorResult
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.request.forms.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.utils.io.core.*
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import net.sergeych.boss_serialization_mp.BossEncoder
|
||||
import net.sergeych.boss_serialization_mp.decodeBoss
|
||||
import net.sergeych.mp_tools.decodeBase64Compact
|
||||
import net.sergeych.mp_tools.encodeToBase64Compact
|
||||
import net.sergeych.mp_tools.globalLaunch
|
||||
import net.sergeych.mptools.CachedExpression
|
||||
import net.sergeych.unikrypto.SymmetricKeys
|
||||
import tools.decodeBase64Url
|
||||
import tools.encodeToBase64Url
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
class CrypstieClient(val rootUrl: String) {
|
||||
|
||||
private val hc = CompletableDeferred<HttpClient>()
|
||||
|
||||
init {
|
||||
globalLaunch {
|
||||
try {
|
||||
hc.complete(HttpClient() {
|
||||
install(ContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
})
|
||||
} catch (x: Exception) {
|
||||
hc.completeExceptionally(x)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val cachedVersion = CachedExpression<String>()
|
||||
|
||||
suspend fun version(): String {
|
||||
return cachedVersion.get {
|
||||
hc.await()
|
||||
.get("$rootUrl/api/version")
|
||||
.body<VersionInfo>()
|
||||
.version
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create encrypted crypstie on the server and return it as a path part of the
|
||||
* of the secret url, e.g. `_guid#haskey`. You should prepend protocol and host to your
|
||||
* web service to use it or whatever else but leave has part as a hash to not to compromise
|
||||
* your data!
|
||||
*/
|
||||
suspend fun createPath(
|
||||
text: String,
|
||||
deleteAt: Instant = Clock.System.now() + 90.days,
|
||||
burnOnShow: Boolean = false,
|
||||
ownerHandle: String? = null,
|
||||
) = createPath(text.toByteArray(), deleteAt, burnOnShow, ownerHandle = ownerHandle)
|
||||
|
||||
/**
|
||||
* Create encrypted crypstie and return its path-patr of the url, e.g. `_guid#haskey`
|
||||
* just add your interface host to it
|
||||
*/
|
||||
suspend fun createPath(
|
||||
data: ByteArray,
|
||||
deleteAt: Instant = Clock.System.now() + 90.days,
|
||||
burnOnShow: Boolean = false,
|
||||
profileIds: List<String>? = null,
|
||||
useWide: Boolean = false,
|
||||
syntaxHighlighting: String? = null,
|
||||
editable: Boolean = false,
|
||||
ownerHandle: String? = null,
|
||||
): String {
|
||||
// Generate random key and encrypt data
|
||||
val k = SymmetricKeys.random()
|
||||
val encrypted = k.etaEncrypt(data)
|
||||
val keyAsString = k.keyBytes.encodeToBase64Url()
|
||||
|
||||
// post crypstie and return URL from the service
|
||||
val response = hc.await().submitFormWithBinaryData(
|
||||
url = "$rootUrl/api/crypstie",
|
||||
formData = formData {
|
||||
append(
|
||||
"data",
|
||||
BossEncoder.encode(
|
||||
ApiCrypstie(
|
||||
"",
|
||||
encrypted, deleteAt, burnOnShow, useWide,
|
||||
syntaxHighlighting, editable, ownerHandle
|
||||
)
|
||||
),
|
||||
Headers.build {
|
||||
append(HttpHeaders.ContentType, "application/octet-stream")
|
||||
append(HttpHeaders.ContentDisposition, "filename=\"dummy.bin\"")
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
val guid = checkResponse(response).body<String>()
|
||||
// now complete it with a key:
|
||||
return "_$guid#$keyAsString"
|
||||
}
|
||||
|
||||
private suspend fun checkResponse(response: HttpResponse): HttpResponse {
|
||||
if (!response.status.isSuccess()) {
|
||||
if (response.status.value == 404)
|
||||
ApiError.INTERNAL_ERROR.raise("404: not found")
|
||||
try {
|
||||
response.body()
|
||||
} catch (x: Exception) {
|
||||
ErrorResult(ApiError.UNKNOWN_ERROR, x.toString())
|
||||
}.raise()
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* donwload and decrypt crypstie by its url path part with hashtah
|
||||
* @param pathPart path part of the url e.g. `_<id>#<encoded_key>`.
|
||||
* @throws ApiError if can't download/decrypt.
|
||||
*/
|
||||
suspend fun decryptPath(pathPart: String, profileIds: List<String>? = null): DecryptedCrypstie {
|
||||
if (pathPart[0] != '_') ApiError.BAD_PARAMETER.raise("pathPart should start with #")
|
||||
val parts = pathPart.split('#')
|
||||
|
||||
fun invalidFormat(text: String? = null): Nothing {
|
||||
ApiError.BAD_PARAMETER.raise(
|
||||
"invalid pathPart format${text?.let { ": $it" } ?: ""} "
|
||||
)
|
||||
}
|
||||
|
||||
if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank())
|
||||
invalidFormat()
|
||||
val key = try {
|
||||
val keyBytes = parts[1].decodeBase64Url()
|
||||
if (keyBytes.size != 32) invalidFormat("wrong hashkey size")
|
||||
SymmetricKeys.create(keyBytes)
|
||||
} catch (x: Exception) {
|
||||
invalidFormat("failed to decode hashkey: $x")
|
||||
}
|
||||
|
||||
val query = if (profileIds.isNullOrEmpty()) ""
|
||||
else "?pids=${profileIds.joinToString(",")}"
|
||||
val packed = checkResponse(hc.await().get("$rootUrl/api/crypstie/${parts[0].substring(1)}$query"))
|
||||
.body<ByteArray>()
|
||||
val ac = packed.decodeBoss<ApiCrypstie>()
|
||||
try {
|
||||
return DecryptedCrypstie(key, ac)
|
||||
} catch (x: Exception) {
|
||||
invalidFormat("failed to decrypt")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
17
src/commonMain/kotlin/crypstie3/DecryptedCrypstie.kt
Normal file
17
src/commonMain/kotlin/crypstie3/DecryptedCrypstie.kt
Normal file
@ -0,0 +1,17 @@
|
||||
package crypstie3
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import net.sergeych.unikrypto.SymmetricKey
|
||||
|
||||
class DecryptedCrypstie(
|
||||
val guid: String,
|
||||
val data: ByteArray,
|
||||
val deleteAt: Instant,
|
||||
val burnOnShow: Boolean,
|
||||
val ownerHandle: String?
|
||||
) {
|
||||
constructor(key: SymmetricKey, ac: ApiCrypstie)
|
||||
: this(ac.guid, key.etaDecrypt(ac.encryptedData), ac.deleteAt, ac.burnOnShow, ac.ownerHandle)
|
||||
|
||||
val text by lazy { data.decodeToString() }
|
||||
}
|
27
src/commonMain/kotlin/crypstie3/errors.kt
Normal file
27
src/commonMain/kotlin/crypstie3/errors.kt
Normal file
@ -0,0 +1,27 @@
|
||||
package api
|
||||
|
||||
enum class ApiError {
|
||||
UNKNOWN_ERROR,
|
||||
BAD_PARAMETER,
|
||||
INTERNAL_ERROR,
|
||||
INVALID_DELETE_DATE,
|
||||
DATA_TOO_BIG,
|
||||
NOT_FOUND;
|
||||
|
||||
fun raise(text: String? = null): Nothing {
|
||||
throw ApiException(this, text ?: defaultText())
|
||||
}
|
||||
|
||||
fun defaultText(): String = name.lowercase().replace('_', ' ')
|
||||
}
|
||||
|
||||
class ApiException(val code: ApiError, _text: String? = null, cause: Throwable? = null) :
|
||||
Exception(_text ?: code.defaultText(), cause) {
|
||||
val text by lazy { _text ?: code.defaultText() }
|
||||
}
|
||||
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
data class ErrorResult(val code: ApiError,val text: String) {
|
||||
fun raise(): Nothing { throw ApiException(code, text) }
|
||||
}
|
72
src/commonMain/kotlin/crypstie3/tools/SimpleStorage.kt
Normal file
72
src/commonMain/kotlin/crypstie3/tools/SimpleStorage.kt
Normal file
@ -0,0 +1,72 @@
|
||||
package tools
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.serializer
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
interface BackingStorge {
|
||||
fun getItem(key: String): String?
|
||||
fun setItem(key: String, value: String)
|
||||
fun clearItem(key: String)
|
||||
}
|
||||
|
||||
class TempBackingStorage : BackingStorge {
|
||||
|
||||
private val m = mutableMapOf<String,String>()
|
||||
|
||||
override fun getItem(key: String): String? = m[key]
|
||||
|
||||
override fun setItem(key: String, value: String) {
|
||||
m[key] = value
|
||||
}
|
||||
|
||||
override fun clearItem(key: String) {
|
||||
m.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
class PrefixedStorage(private val bs: BackingStorge, val prefix: String): BackingStorge {
|
||||
|
||||
override fun getItem(key: String): String? {
|
||||
return bs.getItem(prefix + key)
|
||||
}
|
||||
|
||||
override fun setItem(key: String, value: String) {
|
||||
bs.setItem(prefix+key, value)
|
||||
}
|
||||
|
||||
override fun clearItem(key: String) {
|
||||
bs.clearItem(prefix+key)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val format = Json { prettyPrint = false }
|
||||
|
||||
inline fun <reified T>storage(backingStorge: BackingStorge,overrideName: String?=null): SimpleStorageDelegate<T> {
|
||||
return SimpleStorageDelegate(backingStorge, overrideName, serializer())
|
||||
}
|
||||
|
||||
class SimpleStorageDelegate<T>(
|
||||
val storage: BackingStorge,
|
||||
val overrideName: String? = null,
|
||||
val sr: KSerializer<T>,
|
||||
) {
|
||||
private var cacheIsSet = false
|
||||
private var cachedValue: T? = null
|
||||
|
||||
operator fun getValue(thisRef: Any?, property: KProperty<*>): T? =
|
||||
if (cacheIsSet)
|
||||
cachedValue
|
||||
else
|
||||
storage.getItem(overrideName ?: property.name)?.let { format.decodeFromString(sr, it) }
|
||||
|
||||
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) {
|
||||
val name = overrideName ?: property.name
|
||||
if (value == null) storage.clearItem(name)
|
||||
else storage.setItem(name, format.encodeToString(sr, value))
|
||||
cachedValue = value
|
||||
cacheIsSet = true
|
||||
}
|
||||
}
|
43
src/commonMain/kotlin/crypstie3/tools/catchApi.kt
Normal file
43
src/commonMain/kotlin/crypstie3/tools/catchApi.kt
Normal file
@ -0,0 +1,43 @@
|
||||
package tools
|
||||
|
||||
import api.ApiError
|
||||
import api.ApiException
|
||||
|
||||
class CatchApiResult<T>(_result: T?, val exception: ApiException?) {
|
||||
var errorIsProcessed = false
|
||||
private set
|
||||
|
||||
var result = _result
|
||||
private set
|
||||
|
||||
suspend fun on(vararg errors: ApiError, block: suspend () -> T?): CatchApiResult<T> {
|
||||
if (exception != null && exception.code in errors) {
|
||||
errorIsProcessed = true
|
||||
result = block()
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun ignore(vararg errors: ApiError): CatchApiResult<T> {
|
||||
if (exception != null && exception.code in errors)
|
||||
errorIsProcessed = true
|
||||
return this
|
||||
}
|
||||
|
||||
fun orThrowError(): CatchApiResult<T> {
|
||||
if (exception != null && !errorIsProcessed) throw exception
|
||||
return this
|
||||
}
|
||||
|
||||
fun get(): T? = orThrowError().result
|
||||
}
|
||||
|
||||
|
||||
suspend fun <T> catchApi(block: suspend () -> T): CatchApiResult<T> =
|
||||
try {
|
||||
CatchApiResult(block(), null)
|
||||
} catch (e: ApiException) {
|
||||
CatchApiResult(null, e)
|
||||
} catch (t: Throwable) {
|
||||
CatchApiResult(null, ApiException(ApiError.UNKNOWN_ERROR, cause= t))
|
||||
}
|
21
src/commonMain/kotlin/crypstie3/tools/random_tools.kt
Normal file
21
src/commonMain/kotlin/crypstie3/tools/random_tools.kt
Normal file
@ -0,0 +1,21 @@
|
||||
package tools
|
||||
|
||||
import kotlin.random.Random
|
||||
|
||||
private const val lowerLetters = "qwertyuiopasdfghjklzxcvbnm"
|
||||
private val idFirstLetters: String = lowerLetters + lowerLetters.uppercase() + "_"
|
||||
private val idLetters: String = idFirstLetters + "1234567890-"
|
||||
|
||||
val CharSequence.sampleChar: Char
|
||||
get() = this[Random.nextInt(0,length)]
|
||||
|
||||
val <T> List<T>.sample: T
|
||||
get() = this[Random.nextInt(0,size)]
|
||||
|
||||
fun randomId(length: Int): String {
|
||||
if( length < 2 ) throw IllegalArgumentException("too short")
|
||||
val result = StringBuilder(idFirstLetters.sampleChar.toString())
|
||||
for( i in 1 until length ) result.append(idLetters.sampleChar)
|
||||
return result.toString()
|
||||
}
|
||||
|
30
src/commonMain/kotlin/crypstie3/tools/simple_tools.kt
Normal file
30
src/commonMain/kotlin/crypstie3/tools/simple_tools.kt
Normal file
@ -0,0 +1,30 @@
|
||||
package tools
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import net.sergeych.mp_tools.decodeBase64
|
||||
import net.sergeych.mp_tools.decodeBase64Compact
|
||||
import net.sergeych.mp_tools.encodeToBase64Compact
|
||||
|
||||
suspend fun ignoreErrors(block: suspend ()->Unit) {
|
||||
try {
|
||||
block()
|
||||
}
|
||||
catch(x: CancellationException) {
|
||||
throw x
|
||||
}
|
||||
catch(t: Throwable) {}
|
||||
}
|
||||
|
||||
fun String.trimOrNull(): String?{
|
||||
val s = trim()
|
||||
return if( s == "" ) null else s
|
||||
}
|
||||
|
||||
fun String.decodeBase64Url(): ByteArray = replace('.', '+')
|
||||
.replace('_', '/')
|
||||
.decodeBase64Compact()
|
||||
|
||||
fun ByteArray.encodeToBase64Url(): String = encodeToBase64Compact()
|
||||
.replace('+', '.')
|
||||
.replace('/', '_')
|
||||
|
7
src/commonMain/kotlin/crypstie3/version.kt
Normal file
7
src/commonMain/kotlin/crypstie3/version.kt
Normal file
@ -0,0 +1,7 @@
|
||||
package crypstie3
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class VersionInfo(val version: String,val service: String)
|
||||
|
14
src/commonTest/kotlin/CrypstieClientTest.kt
Normal file
14
src/commonTest/kotlin/CrypstieClientTest.kt
Normal file
@ -0,0 +1,14 @@
|
||||
import crypstie3.CrypstieClient
|
||||
import kotlin.test.*
|
||||
|
||||
internal class CrypstieClientTest {
|
||||
|
||||
@Test
|
||||
fun create() = runTest {
|
||||
val client = CrypstieClient("http://localhost:8296")
|
||||
assertEquals(3, client.version().split('.').size)
|
||||
val x = client.createPath("foobarbazz")
|
||||
val dc = client.decryptPath(x)
|
||||
assertEquals("foobarbazz", dc.text)
|
||||
}
|
||||
}
|
4
src/commonTest/kotlin/runTest.kt
Normal file
4
src/commonTest/kotlin/runTest.kt
Normal file
@ -0,0 +1,4 @@
|
||||
import net.sergeych.mp_tools.globalLaunch
|
||||
|
||||
expect fun runTest(block: suspend () -> Unit)
|
||||
|
4
src/jsTest/kotlin/runTest.kt
Normal file
4
src/jsTest/kotlin/runTest.kt
Normal file
@ -0,0 +1,4 @@
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.promise
|
||||
|
||||
actual fun runTest(block: suspend () -> Unit): dynamic = GlobalScope.promise { block() }
|
6
src/jvmMain/kotlin/date_tools.kt
Normal file
6
src/jvmMain/kotlin/date_tools.kt
Normal file
@ -0,0 +1,6 @@
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.toKotlinInstant
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
fun ZonedDateTime.toKotlinInstant(): Instant = toInstant().toKotlinInstant()
|
||||
|
5
src/jvmTest/kotlin/runTest.kt
Normal file
5
src/jvmTest/kotlin/runTest.kt
Normal file
@ -0,0 +1,5 @@
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
actual fun runTest(block: suspend () -> Unit) {
|
||||
runBlocking { block() }
|
||||
}
|
Loading…
Reference in New Issue
Block a user