Merge pull request #205 from InsanusMokrassar/0.14.3

0.14.3
This commit is contained in:
InsanusMokrassar 2022-11-24 13:15:08 +06:00 committed by GitHub
commit 11116d8cab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 503 additions and 63 deletions

View File

@ -9,9 +9,6 @@ jobs:
- uses: actions/setup-java@v1 - uses: actions/setup-java@v1
with: with:
java-version: 11 java-version: 11
- name: Fix android 32.0.0 dx
continue-on-error: true
run: cd /usr/local/lib/android/sdk/build-tools/32.0.0/ && mv d8 dx && cd lib && mv d8.jar dx.jar
- name: Rewrite version - name: Rewrite version
run: | run: |
branch="`echo "${{ github.ref }}" | grep -o "[^/]*$"`" branch="`echo "${{ github.ref }}" | grep -o "[^/]*$"`"
@ -20,9 +17,8 @@ jobs:
mv gradle.properties.tmp gradle.properties mv gradle.properties.tmp gradle.properties
- name: Build - name: Build
run: ./gradlew build run: ./gradlew build
# - name: Publish - name: Publish
# continue-on-error: true continue-on-error: true
# run: ./gradlew --no-parallel publishAllPublicationsToGithubPackagesRepository run: ./gradlew publishAllPublicationsToGiteaRepository
# env: env:
# GITHUBPACKAGES_USER: ${{ github.actor }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
# GITHUBPACKAGES_PASSWORD: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,5 +1,16 @@
# Changelog # Changelog
## 0.14.3
* `Common`:
* New type `Progress`
* `Ktor`:
* `Client`:
* New universal `uniUpload` extension for `HttpClient`
* `Server`:
* New universal `handleUniUpload` extension for `ApplicationCall`
* Add extensions `download` and `downloadToTemporalFile`
## 0.14.2 ## 0.14.2
* `Versions`: * `Versions`:

View File

@ -0,0 +1,37 @@
package dev.inmo.micro_utils.common
import kotlinx.serialization.Serializable
import kotlin.jvm.JvmInline
@Serializable
@JvmInline
value class Progress private constructor(
val of1: Double
) {
val of1Float
get() = of1.toFloat()
val of100
get() = of1 * 100
val of100Float
get() = of100.toFloat()
val of100Int
get() = of100.toInt()
init {
require(of1 in rangeOfValues) {
"Progress main value should be in $rangeOfValues, but incoming value is $of1"
}
}
companion object {
val rangeOfValues = 0.0 .. 1.0
val START = Progress(rangeOfValues.start)
val COMPLETED = Progress(rangeOfValues.endInclusive)
operator fun invoke(of1: Double) = Progress(of1.coerceIn(rangeOfValues))
operator fun invoke(part: Number, total: Number) = Progress(
part.toDouble() / total.toDouble()
)
}
}

View File

@ -0,0 +1,80 @@
@file:Suppress(
"RemoveRedundantCallsOfConversionMethods",
"RedundantVisibilityModifier",
)
package dev.inmo.micro_utils.common
import kotlin.Byte
import kotlin.Double
import kotlin.Float
import kotlin.Int
import kotlin.Long
import kotlin.Short
import kotlin.Suppress
public operator fun Progress.plus(other: Progress): Progress = Progress(of1 + other.of1)
public operator fun Progress.minus(other: Progress): Progress = Progress(of1 - other.of1)
public operator fun Progress.plus(i: Byte): Progress = Progress((of1 + i).toDouble())
public operator fun Progress.minus(i: Byte): Progress = Progress((of1 - i).toDouble())
public operator fun Progress.times(i: Byte): Progress = Progress((of1 * i).toDouble())
public operator fun Progress.div(i: Byte): Progress = Progress((of1 / i).toDouble())
public operator fun Progress.rem(i: Byte): Progress = Progress((of1 % i).toDouble())
public operator fun Progress.plus(i: Short): Progress = Progress((of1 + i).toDouble())
public operator fun Progress.minus(i: Short): Progress = Progress((of1 - i).toDouble())
public operator fun Progress.times(i: Short): Progress = Progress((of1 * i).toDouble())
public operator fun Progress.div(i: Short): Progress = Progress((of1 / i).toDouble())
public operator fun Progress.rem(i: Short): Progress = Progress((of1 % i).toDouble())
public operator fun Progress.plus(i: Int): Progress = Progress((of1 + i).toDouble())
public operator fun Progress.minus(i: Int): Progress = Progress((of1 - i).toDouble())
public operator fun Progress.times(i: Int): Progress = Progress((of1 * i).toDouble())
public operator fun Progress.div(i: Int): Progress = Progress((of1 / i).toDouble())
public operator fun Progress.rem(i: Int): Progress = Progress((of1 % i).toDouble())
public operator fun Progress.plus(i: Long): Progress = Progress((of1 + i).toDouble())
public operator fun Progress.minus(i: Long): Progress = Progress((of1 - i).toDouble())
public operator fun Progress.times(i: Long): Progress = Progress((of1 * i).toDouble())
public operator fun Progress.div(i: Long): Progress = Progress((of1 / i).toDouble())
public operator fun Progress.rem(i: Long): Progress = Progress((of1 % i).toDouble())
public operator fun Progress.plus(i: Float): Progress = Progress((of1 + i).toDouble())
public operator fun Progress.minus(i: Float): Progress = Progress((of1 - i).toDouble())
public operator fun Progress.times(i: Float): Progress = Progress((of1 * i).toDouble())
public operator fun Progress.div(i: Float): Progress = Progress((of1 / i).toDouble())
public operator fun Progress.rem(i: Float): Progress = Progress((of1 % i).toDouble())
public operator fun Progress.plus(i: Double): Progress = Progress((of1 + i).toDouble())
public operator fun Progress.minus(i: Double): Progress = Progress((of1 - i).toDouble())
public operator fun Progress.times(i: Double): Progress = Progress((of1 * i).toDouble())
public operator fun Progress.div(i: Double): Progress = Progress((of1 / i).toDouble())
public operator fun Progress.rem(i: Double): Progress = Progress((of1 % i).toDouble())
public operator fun Progress.compareTo(other: Progress): Int = (of1 - other.of1).toInt()

View File

@ -14,5 +14,5 @@ crypto_js_version=4.1.1
# Project data # Project data
group=dev.inmo group=dev.inmo
version=0.14.2 version=0.14.3
android_code_version=163 android_code_version=164

View File

@ -15,5 +15,9 @@ kotlin {
api libs.ktor.client api libs.ktor.client
} }
} }
androidMain {
dependsOn jvmMain
}
} }
} }

View File

@ -0,0 +1,3 @@
package dev.inmo.micro_utils.ktor.client
typealias OnUploadCallback = suspend (uploaded: Long, count: Long) -> Unit

View File

@ -7,5 +7,5 @@ import io.ktor.client.HttpClient
expect suspend fun HttpClient.tempUpload( expect suspend fun HttpClient.tempUpload(
fullTempUploadDraftPath: String, fullTempUploadDraftPath: String,
file: MPPFile, file: MPPFile,
onUpload: (uploaded: Long, count: Long) -> Unit = { _, _ -> } onUpload: OnUploadCallback = { _, _ -> }
): TemporalFileId ): TemporalFileId

View File

@ -0,0 +1,31 @@
package dev.inmo.micro_utils.ktor.client
import dev.inmo.micro_utils.common.FileName
import dev.inmo.micro_utils.ktor.common.LambdaInputProvider
import io.ktor.client.HttpClient
import io.ktor.http.Headers
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.StringFormat
import kotlinx.serialization.json.Json
data class UniUploadFileInfo(
val fileName: FileName,
val mimeType: String,
val inputAllocator: LambdaInputProvider
)
/**
* Will execute submitting of multipart data request
*
* @param data [Map] where keys will be used as names for multipart parts and values as values. If you will pass
* [dev.inmo.micro_utils.common.MPPFile] (File from JS or JVM platform). Also you may pass [UniUploadFileInfo] as value
* in case you wish to pass other source of multipart binary data than regular file
*/
expect suspend fun <T> HttpClient.uniUpload(
url: String,
data: Map<String, Any>,
resultDeserializer: DeserializationStrategy<T>,
headers: Headers = Headers.Empty,
stringFormat: StringFormat = Json,
onUpload: OnUploadCallback = { _, _ -> }
): T?

View File

@ -1,6 +1,8 @@
package dev.inmo.micro_utils.ktor.client package dev.inmo.micro_utils.ktor.client
import dev.inmo.micro_utils.common.MPPFile import dev.inmo.micro_utils.common.MPPFile
import dev.inmo.micro_utils.coroutines.LinkedSupervisorJob
import dev.inmo.micro_utils.coroutines.launchSafelyWithoutExceptions
import dev.inmo.micro_utils.ktor.common.TemporalFileId import dev.inmo.micro_utils.ktor.common.TemporalFileId
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import kotlinx.coroutines.* import kotlinx.coroutines.*
@ -12,10 +14,11 @@ import org.w3c.xhr.XMLHttpRequest.Companion.DONE
suspend fun tempUpload( suspend fun tempUpload(
fullTempUploadDraftPath: String, fullTempUploadDraftPath: String,
file: MPPFile, file: MPPFile,
onUpload: (Long, Long) -> Unit onUpload: OnUploadCallback
): TemporalFileId { ): TemporalFileId {
val formData = FormData() val formData = FormData()
val answer = CompletableDeferred<TemporalFileId>(currentCoroutineContext().job) val answer = CompletableDeferred<TemporalFileId>(currentCoroutineContext().job)
val subscope = CoroutineScope(currentCoroutineContext().LinkedSupervisorJob())
formData.append( formData.append(
"data", "data",
@ -25,7 +28,7 @@ suspend fun tempUpload(
val request = XMLHttpRequest() val request = XMLHttpRequest()
request.responseType = XMLHttpRequestResponseType.TEXT request.responseType = XMLHttpRequestResponseType.TEXT
request.upload.onprogress = { request.upload.onprogress = {
onUpload(it.loaded.toLong(), it.total.toLong()) subscope.launchSafelyWithoutExceptions { onUpload(it.loaded.toLong(), it.total.toLong()) }
} }
request.onload = { request.onload = {
if (request.status == 200.toShort()) { if (request.status == 200.toShort()) {
@ -48,12 +51,14 @@ suspend fun tempUpload(
} }
} }
return answer.await() return answer.await().also {
subscope.cancel()
}
} }
actual suspend fun HttpClient.tempUpload( actual suspend fun HttpClient.tempUpload(
fullTempUploadDraftPath: String, fullTempUploadDraftPath: String,
file: MPPFile, file: MPPFile,
onUpload: (uploaded: Long, count: Long) -> Unit onUpload: OnUploadCallback
): TemporalFileId = dev.inmo.micro_utils.ktor.client.tempUpload(fullTempUploadDraftPath, file, onUpload) ): TemporalFileId = dev.inmo.micro_utils.ktor.client.tempUpload(fullTempUploadDraftPath, file, onUpload)

View File

@ -0,0 +1,96 @@
package dev.inmo.micro_utils.ktor.client
import dev.inmo.micro_utils.common.MPPFile
import dev.inmo.micro_utils.common.Progress
import dev.inmo.micro_utils.coroutines.LinkedSupervisorJob
import dev.inmo.micro_utils.coroutines.launchSafelyWithoutExceptions
import io.ktor.client.HttpClient
import io.ktor.http.Headers
import io.ktor.utils.io.core.readBytes
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.job
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.StringFormat
import kotlinx.serialization.encodeToString
import org.khronos.webgl.Int8Array
import org.w3c.files.Blob
import org.w3c.xhr.FormData
import org.w3c.xhr.TEXT
import org.w3c.xhr.XMLHttpRequest
import org.w3c.xhr.XMLHttpRequestResponseType
/**
* Will execute submitting of multipart data request
*
* @param data [Map] where keys will be used as names for multipart parts and values as values. If you will pass
* [dev.inmo.micro_utils.common.MPPFile] (File from JS or JVM platform). Also you may pass [UniUploadFileInfo] as value
* in case you wish to pass other source of multipart binary data than regular file
*/
actual suspend fun <T> HttpClient.uniUpload(
url: String,
data: Map<String, Any>,
resultDeserializer: DeserializationStrategy<T>,
headers: Headers,
stringFormat: StringFormat,
onUpload: OnUploadCallback
): T? {
val formData = FormData()
val answer = CompletableDeferred<T?>(currentCoroutineContext().job)
val subscope = CoroutineScope(currentCoroutineContext().LinkedSupervisorJob())
data.forEach { (k, v) ->
when (v) {
is MPPFile -> formData.append(
k,
v
)
is UniUploadFileInfo -> formData.append(
k,
Blob(arrayOf(Int8Array(v.inputAllocator().readBytes().toTypedArray()))),
v.fileName.name
)
else -> formData.append(
k,
stringFormat.encodeToString(v)
)
}
}
val request = XMLHttpRequest()
headers.forEach { s, strings ->
request.setRequestHeader(s, strings.joinToString())
}
request.responseType = XMLHttpRequestResponseType.TEXT
request.upload.onprogress = {
subscope.launchSafelyWithoutExceptions { onUpload(it.loaded.toLong(), it.total.toLong()) }
}
request.onload = {
if (request.status == 200.toShort()) {
answer.complete(
stringFormat.decodeFromString(resultDeserializer, request.responseText)
)
} else {
answer.completeExceptionally(Exception("Something went wrong: $it"))
}
}
request.onerror = {
answer.completeExceptionally(Exception("Something went wrong: $it"))
}
request.open("POST", url, true)
request.send(formData)
answer.invokeOnCompletion {
runCatching {
if (request.readyState != XMLHttpRequest.DONE) {
request.abort()
}
}
}
return answer.await().also {
subscope.cancel()
}
}

View File

@ -4,6 +4,8 @@ import dev.inmo.micro_utils.common.MPPFile
import io.ktor.client.request.forms.InputProvider import io.ktor.client.request.forms.InputProvider
import io.ktor.utils.io.streams.asInput import io.ktor.utils.io.streams.asInput
actual suspend fun MPPFile.inputProvider(): InputProvider = InputProvider(length()) { fun MPPFile.inputProviderSync(): InputProvider = InputProvider(length()) {
inputStream().asInput() inputStream().asInput()
} }
actual suspend fun MPPFile.inputProvider(): InputProvider = inputProviderSync()

View File

@ -18,7 +18,7 @@ internal val MPPFile.mimeType: String
actual suspend fun HttpClient.tempUpload( actual suspend fun HttpClient.tempUpload(
fullTempUploadDraftPath: String, fullTempUploadDraftPath: String,
file: MPPFile, file: MPPFile,
onUpload: (Long, Long) -> Unit onUpload: OnUploadCallback
): TemporalFileId { ): TemporalFileId {
val inputProvider = file.inputProvider() val inputProvider = file.inputProvider()
val fileId = submitFormWithBinaryData( val fileId = submitFormWithBinaryData(

View File

@ -0,0 +1,101 @@
package dev.inmo.micro_utils.ktor.client
import dev.inmo.micro_utils.common.Progress
import io.ktor.client.HttpClient
import io.ktor.client.engine.mergeHeaders
import io.ktor.client.plugins.onUpload
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.forms.InputProvider
import io.ktor.client.request.forms.formData
import io.ktor.client.request.forms.submitForm
import io.ktor.client.request.forms.submitFormWithBinaryData
import io.ktor.client.request.headers
import io.ktor.client.statement.bodyAsText
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.Parameters
import io.ktor.http.content.PartData
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.StringFormat
import kotlinx.serialization.encodeToString
import java.io.File
/**
* Will execute submitting of multipart data request
*
* @param data [Map] where keys will be used as names for multipart parts and values as values. If you will pass
* [dev.inmo.micro_utils.common.MPPFile] (File from JS or JVM platform). Also you may pass [UniUploadFileInfo] as value
* in case you wish to pass other source of multipart binary data than regular file
*/
actual suspend fun <T> HttpClient.uniUpload(
url: String,
data: Map<String, Any>,
resultDeserializer: DeserializationStrategy<T>,
headers: Headers,
stringFormat: StringFormat,
onUpload: OnUploadCallback
): T? {
val withBinary = data.values.any { it is File || it is UniUploadFileInfo }
val formData = formData {
data.forEach { (k, v) ->
when (v) {
is File -> append(
k,
v.inputProviderSync(),
Headers.build {
append(HttpHeaders.ContentType, v.mimeType)
append(HttpHeaders.ContentDisposition, "filename=\"${v.name}\"")
}
)
is UniUploadFileInfo -> append(
k,
InputProvider(block = v.inputAllocator),
Headers.build {
append(HttpHeaders.ContentType, v.mimeType)
append(HttpHeaders.ContentDisposition, "filename=\"${v.fileName.name}\"")
}
)
else -> append(
k,
stringFormat.encodeToString(v)
)
}
}
}
val requestBuilder: HttpRequestBuilder.() -> Unit = {
headers {
appendAll(headers)
}
onUpload { bytesSentTotal, contentLength ->
onUpload(bytesSentTotal, contentLength)
}
}
val response = if (withBinary) {
submitFormWithBinaryData(
url,
formData,
block = requestBuilder
)
} else {
submitForm(
url,
Parameters.build {
formData.forEach {
val formItem = (it as PartData.FormItem)
append(it.name!!, it.value)
}
},
block = requestBuilder
)
}
return if (response.status == HttpStatusCode.OK) {
stringFormat.decodeFromString(resultDeserializer, response.bodyAsText())
} else {
null
}
}

View File

@ -0,0 +1,5 @@
package dev.inmo.micro_utils.ktor.common
import io.ktor.utils.io.core.Input
typealias LambdaInputProvider = () -> Input

View File

@ -0,0 +1,56 @@
package dev.inmo.micro_utils.ktor.server
import com.benasher44.uuid.uuid4
import io.ktor.http.content.PartData
import io.ktor.utils.io.copyTo
import io.ktor.utils.io.core.copyTo
import io.ktor.utils.io.jvm.javaio.copyTo
import io.ktor.utils.io.streams.asOutput
import java.io.File
fun PartData.FileItem.download(target: File) {
provider().use { input ->
target.outputStream().use {
input.copyTo(it.asOutput())
}
}
}
fun PartData.FileItem.downloadToTemporalFile(): File {
val outputFile = File.createTempFile(uuid4().toString(), ".temp").apply {
deleteOnExit()
}
download(outputFile)
return outputFile
}
fun PartData.BinaryItem.download(target: File) {
provider().use { input ->
target.outputStream().use {
input.copyTo(it.asOutput())
}
}
}
fun PartData.BinaryItem.downloadToTemporalFile(): File {
val outputFile = File.createTempFile(uuid4().toString(), ".temp").apply {
deleteOnExit()
}
download(outputFile)
return outputFile
}
suspend fun PartData.BinaryChannelItem.download(target: File) {
val input = provider()
target.outputStream().use {
input.copyTo(it)
}
}
suspend fun PartData.BinaryChannelItem.downloadToTemporalFile(): File {
val outputFile = File.createTempFile(uuid4().toString(), ".temp").apply {
deleteOnExit()
}
download(outputFile)
return outputFile
}

View File

@ -0,0 +1,25 @@
package dev.inmo.micro_utils.ktor.server
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.response.respond
suspend fun ApplicationCall.getParameterOrSendError(
field: String
) = parameters[field].also {
if (it == null) {
respond(HttpStatusCode.BadRequest, "Request must contains $field")
}
}
fun ApplicationCall.getQueryParameter(
field: String
) = request.queryParameters[field]
suspend fun ApplicationCall.getQueryParameterOrSendError(
field: String
) = getQueryParameter(field).also {
if (it == null) {
respond(HttpStatusCode.BadRequest, "Request query parameters must contains $field")
}
}

View File

@ -2,22 +2,34 @@ package dev.inmo.micro_utils.ktor.server
import dev.inmo.micro_utils.common.* import dev.inmo.micro_utils.common.*
import dev.inmo.micro_utils.coroutines.safely import dev.inmo.micro_utils.coroutines.safely
import dev.inmo.micro_utils.ktor.common.*
import io.ktor.http.*
import io.ktor.http.content.* import io.ktor.http.content.*
import io.ktor.server.application.ApplicationCall import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.request.receive
import io.ktor.server.request.receiveMultipart import io.ktor.server.request.receiveMultipart
import io.ktor.server.response.respond
import io.ktor.server.response.respondBytes
import io.ktor.server.routing.Route
import io.ktor.server.websocket.WebSocketServerSession
import io.ktor.util.pipeline.PipelineContext
import io.ktor.utils.io.core.* import io.ktor.utils.io.core.*
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.currentCoroutineContext
import kotlinx.serialization.DeserializationStrategy import kotlinx.coroutines.isActive
import kotlinx.serialization.SerializationStrategy
/**
* Server-side part which receives [dev.inmo.micro_utils.ktor.client.uniUpload] request
*/
suspend inline fun ApplicationCall.handleUniUpload(
onFormItem: (PartData.FormItem) -> Unit = {},
onBinaryContent: (PartData.BinaryItem) -> Unit = {},
onBinaryChannelItem: (PartData.BinaryChannelItem) -> Unit = {},
onFileItem: (PartData.FileItem) -> Unit = {}
) {
val multipartData = receiveMultipart()
while (currentCoroutineContext().isActive) {
val partData = multipartData.readPart() ?: break
when (partData) {
is PartData.FormItem -> onFormItem(partData)
is PartData.FileItem -> onFileItem(partData)
is PartData.BinaryItem -> onBinaryContent(partData)
is PartData.BinaryChannelItem -> onBinaryChannelItem(partData)
}
}
}
suspend fun ApplicationCall.uniloadMultipart( suspend fun ApplicationCall.uniloadMultipart(
onFormItem: (PartData.FormItem) -> Unit = {}, onFormItem: (PartData.FormItem) -> Unit = {},
@ -25,21 +37,16 @@ suspend fun ApplicationCall.uniloadMultipart(
onBinaryChannelItem: (PartData.BinaryChannelItem) -> Unit = {}, onBinaryChannelItem: (PartData.BinaryChannelItem) -> Unit = {},
onBinaryContent: (PartData.BinaryItem) -> Unit = {} onBinaryContent: (PartData.BinaryItem) -> Unit = {}
) = safely { ) = safely {
val multipartData = receiveMultipart()
var resultInput: Input? = null var resultInput: Input? = null
multipartData.forEachPart { handleUniUpload(
when (it) { onFormItem,
is PartData.FormItem -> onFormItem(it) onBinaryContent,
is PartData.FileItem -> { onBinaryChannelItem
when (it.name) { ) {
"bytes" -> resultInput = it.provider() when (it.name) {
else -> onCustomFileItem(it) "bytes" -> resultInput = it.provider()
} else -> onCustomFileItem(it)
}
is PartData.BinaryItem -> onBinaryContent(it)
is PartData.BinaryChannelItem -> onBinaryChannelItem(it)
} }
} }
@ -89,23 +96,3 @@ suspend fun ApplicationCall.uniloadMultipartFile(
resultInput ?: error("Bytes has not been received") resultInput ?: error("Bytes has not been received")
} }
suspend fun ApplicationCall.getParameterOrSendError(
field: String
) = parameters[field].also {
if (it == null) {
respond(HttpStatusCode.BadRequest, "Request must contains $field")
}
}
fun ApplicationCall.getQueryParameter(
field: String
) = request.queryParameters[field]
suspend fun ApplicationCall.getQueryParameterOrSendError(
field: String
) = getQueryParameter(field).also {
if (it == null) {
respond(HttpStatusCode.BadRequest, "Request query parameters must contains $field")
}
}

View File

@ -28,6 +28,7 @@ kotlin {
api internalProject("micro_utils.common") api internalProject("micro_utils.common")
api internalProject("micro_utils.coroutines") api internalProject("micro_utils.coroutines")
} }
dependsOn jvmMain
} }
} }
} }