diff --git a/build.gradle.kts b/build.gradle.kts index 9600a71..8de9756 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ val kotlin_version: String by project plugins { kotlin("multiplatform") version "1.7.10" kotlin("plugin.serialization") version "1.7.10" + id("org.jetbrains.dokka") version "1.7.10" `maven-publish` } diff --git a/src/commonMain/kotlin/net.sergeych.parsec3/Adapter.kt b/src/commonMain/kotlin/net.sergeych.parsec3/Adapter.kt index 4b3cefc..e923415 100644 --- a/src/commonMain/kotlin/net.sergeych.parsec3/Adapter.kt +++ b/src/commonMain/kotlin/net.sergeych.parsec3/Adapter.kt @@ -1,4 +1,4 @@ -package channel +package net.sergeych.parsec3 import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.sync.Mutex @@ -7,15 +7,11 @@ import kotlinx.serialization.json.Json import net.sergeych.boss_serialization.BossDecoder import net.sergeych.boss_serialization_mp.BossEncoder import net.sergeych.boss_serialization_mp.decodeBoss -import net.sergeych.cloudoc.api.Package import net.sergeych.mp_logger.LogTag import net.sergeych.mp_logger.debug import net.sergeych.mp_logger.exception import net.sergeych.mp_logger.warning import net.sergeych.mptools.toDump -import net.sergeych.parsec3.ExceptionsRegistry -import net.sergeych.parsec3.InvalidFrameException -import net.sergeych.parsec3.ParsecException /** * Create adapter, an interface to provide local API commands and invoke remote API commands @@ -82,7 +78,6 @@ open class Adapter( private var lastId = 1 private val access = Mutex() - /** * Call the remote party for a type command. See [CommandHost] on how to declare and implement * such commands in parsec3. Suspends until receiving answer from a remote party. @@ -93,7 +88,7 @@ open class Adapter( * @return value from remote partm any serializable type. */ @Suppress("UNCHECKED_CAST") - suspend fun invokeCommand(ca: CommandDescriptor, args: A = Unit as A): R { + suspend fun invokeCommand(ca: CommandDescriptor, args: A = Unit as A): R { var myId = -1 return CompletableDeferred().also { dr -> sendPackage( diff --git a/src/commonMain/kotlin/net.sergeych.parsec3/AdapterBuilder.kt b/src/commonMain/kotlin/net.sergeych.parsec3/AdapterBuilder.kt new file mode 100644 index 0000000..8f13869 --- /dev/null +++ b/src/commonMain/kotlin/net.sergeych.parsec3/AdapterBuilder.kt @@ -0,0 +1,40 @@ +package net.sergeych.parsec3 + +import kotlinx.coroutines.flow.Flow +import net.sergeych.mp_tools.globalLaunch + +class AdapterBuilder>( + val api: H, + private val exceptionRegistry: ExceptionsRegistry = ExceptionsRegistry(), + f: AdapterBuilder.() -> Unit, +) { + + internal var sessionProducer: (suspend () -> S)? = null + private set + + + fun newSession(f: suspend () -> S) { + sessionProducer = f + } + + /** + * Register command implementation + */ + fun on(ca: CommandDescriptor, block: suspend S.(A) -> R) { + api.on(ca, block) + } + + fun addError(code: String, handler: (String?) -> T) { + exceptionRegistry.register(code, handler) + } + + suspend fun createWith(input: Flow, f: suspend (ByteArray)->Unit ): Adapter { + return Adapter(sessionProducer!!(), api, exceptionRegistry) { f(it) } + .also { a-> globalLaunch { input.collect { a.receiveFrame(it)} } } + } + + init { + f(this) + } + +} \ No newline at end of file diff --git a/src/commonMain/kotlin/net.sergeych.parsec3/AdapterDelegate.kt b/src/commonMain/kotlin/net.sergeych.parsec3/AdapterDelegate.kt index b5e72ed..cbde351 100644 --- a/src/commonMain/kotlin/net.sergeych.parsec3/AdapterDelegate.kt +++ b/src/commonMain/kotlin/net.sergeych.parsec3/AdapterDelegate.kt @@ -1,6 +1,5 @@ package net.sergeych.parsec3 -import channel.CommandDescriptor import kotlin.reflect.KProperty import kotlin.reflect.KType @@ -13,7 +12,7 @@ class AdapterDelegate( val ass: KType, val rss: KType, ) { - operator fun getValue(thisRef: Any?, property: KProperty<*>): CommandDescriptor { + operator fun getValue(thisRef: Any?, property: KProperty<*>): CommandDescriptor { return CommandDescriptor( overrideName ?: property.name, ass, rss diff --git a/src/commonMain/kotlin/net.sergeych.parsec3/CommandDescriptor.kt b/src/commonMain/kotlin/net.sergeych.parsec3/CommandDescriptor.kt index 59d4570..7dc0599 100644 --- a/src/commonMain/kotlin/net.sergeych.parsec3/CommandDescriptor.kt +++ b/src/commonMain/kotlin/net.sergeych.parsec3/CommandDescriptor.kt @@ -1,19 +1,19 @@ -package channel +package net.sergeych.parsec3 import kotlin.reflect.KType -class CommandDescriptor( +class CommandDescriptor( val name: String, val ass: KType, val rss: KType, ) { - suspend operator fun invoke(adapter: Adapter, args: A): R = + suspend operator fun invoke(adapter: Adapter<*>, args: A): R = adapter.invokeCommand(this, args) @Suppress("UNCHECKED_CAST") - suspend operator fun invoke(adapter: Adapter): R = adapter.invokeCommand(this,Unit as A) + suspend operator fun invoke(adapter: Adapter<*>): R = adapter.invokeCommand(this,Unit as A) - operator fun invoke(commandHost: CommandHost, block: suspend I.(A)->R) { + operator fun invoke(commandHost: CommandHost, block: suspend I.(A)->R) { commandHost.on(this, block) } } diff --git a/src/commonMain/kotlin/net.sergeych.parsec3/CommandHost.kt b/src/commonMain/kotlin/net.sergeych.parsec3/CommandHost.kt index 04b637b..5e85c1c 100644 --- a/src/commonMain/kotlin/net.sergeych.parsec3/CommandHost.kt +++ b/src/commonMain/kotlin/net.sergeych.parsec3/CommandHost.kt @@ -1,9 +1,7 @@ -package channel +package net.sergeych.parsec3 import net.sergeych.boss_serialization.BossDecoder import net.sergeych.boss_serialization_mp.BossEncoder -import net.sergeych.parsec3.AdapterDelegate -import net.sergeych.parsec3.CommandNotFoundException import kotlin.reflect.typeOf /** @@ -39,7 +37,7 @@ open class CommandHost { * Provide implementation for a specific command in type-safe compile-time checked manner. the command * should be declared with [command] invocation. */ - fun on(ca: CommandDescriptor, block: suspend T.(A) -> R) { + fun on(ca: CommandDescriptor, block: suspend T.(A) -> R) { handlers[ca.name] = {args -> val decodedArgs = BossDecoder.decodeFrom(ca.ass, args) BossEncoder.encode(ca.rss, block(decodedArgs)) diff --git a/src/commonMain/kotlin/net.sergeych.parsec3/Package.kt b/src/commonMain/kotlin/net.sergeych.parsec3/Package.kt index c8f20a8..656256d 100644 --- a/src/commonMain/kotlin/net.sergeych.parsec3/Package.kt +++ b/src/commonMain/kotlin/net.sergeych.parsec3/Package.kt @@ -1,13 +1,32 @@ -package net.sergeych.cloudoc.api +package net.sergeych.parsec3 import kotlinx.serialization.Serializable +/** + * The parsec3 package transmit requests and responses over the parsec3 channel. + */ @Serializable sealed class Package { - + /** + * Invoke a remote command. + * @param id command is a monotonously growing number, that could reset to 0 after getting close + * to `Int.MAX_VALUE`, for example. It uniquely identifies a command that is waiting for an answer. + * @param name command's name + * @param args whatever arguments the command accepts serialized with BOSS. + */ @Serializable data class Command(val id: Int, val name: String, val args: ByteArray) : Package() + + /** + * Response to a previously issued command. See [ExceptionsRegistry] and [ParsecException] for more information + * on passing errors. + * + * @param tiId if of the command this response is for + * @param result packed result. If null, it means the command has thrown an exception and [errorCode] must + * not be null + * @param errorCode exception code, if not null then result must be ignored (and assumed to be null). + */ @Serializable data class Response( val toId: Int, diff --git a/src/commonTest/kotlin/parsec3/AdapterTest.kt b/src/commonTest/kotlin/parsec3/AdapterTest.kt index 072e3e5..c11efa9 100644 --- a/src/commonTest/kotlin/parsec3/AdapterTest.kt +++ b/src/commonTest/kotlin/parsec3/AdapterTest.kt @@ -1,32 +1,34 @@ package parsec3 -import channel.Adapter -import channel.CommandHost +import assertThrows import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest +import net.sergeych.parsec3.* import kotlin.test.Test import kotlin.test.assertEquals internal class AdapterTest { - class Api1: CommandHost() { + object Api1 : CommandHost() { // create command `foo` that takes a string argument and // returns a string: - val foo by command() + val foo by command() } - class Api2: CommandHost() { - val bar by command() + object Api2 : CommandHost() { + + val bar by command() } @Test fun interconnect() = runTest { val ch12 = Channel() val ch21 = Channel() - val api1 = Api1() - val api2 = Api2() + val api1 = Api1 + val api2 = Api2 api1.on(api1.foo) { it + "foo" } @@ -34,10 +36,10 @@ internal class AdapterTest { it + "bar" } - val a1 = Adapter(Unit,api1) { ch12.send(it) } - val a2 = Adapter(Unit,api2) { ch21.send(it) } - launch { for( b in ch12) a2.receiveFrame(b) } - launch { for( b in ch21) a1.receiveFrame(b) } + val a1 = Adapter(Unit, api1) { ch12.send(it) } + val a2 = Adapter(Unit, api2) { ch21.send(it) } + launch { for (b in ch12) a2.receiveFrame(b) } + launch { for (b in ch21) a1.receiveFrame(b) } assertEquals("123bar", a1.invokeCommand(api2.bar, "123")) assertEquals("321foo", a2.invokeCommand(api1.foo, "321")) @@ -46,4 +48,58 @@ internal class AdapterTest { ch21.cancel() } + + data class TestSession(var buzz: String) + + object ApiS1 : CommandHost() { + // create command `foo` that takes a string argument and + // returns a string: + val foo by command() + val ex by command() + } + class ApiS2 : CommandHost() { + // create command `foo` that takes a string argument and + // returns a string: + val bar by command() + } + + @Test + fun builderTest() = runTest { + val ch12 = Channel() + val ch21 = Channel() + + val er = ExceptionsRegistry().also { + it.register("foo_x") { IllegalArgumentException("foo_x") } + } + + val b1 = AdapterBuilder(ApiS1, er) { + newSession { TestSession("42") } + on(api.foo) { + it + buzz + "foo" + } + on(ApiS1.ex) { + throw ParsecException("foo_x") + } + } + val b2 = AdapterBuilder(ApiS2(), er) { + newSession { } + on(api.bar) { + it + "bar" + } + } + + val a1 = b1.createWith(ch21.receiveAsFlow()) { ch12.send(it) } + val a2 = b2.createWith(ch12.receiveAsFlow()) { ch21.send(it) } + + assertEquals("123bar", a1.invokeCommand(ApiS2().bar, "123")) + assertEquals("32142foo", a2.invokeCommand(ApiS1.foo, "321")) + + assertEquals("---42foo", ApiS1.foo.invoke(a2, "---")) + assertThrows { ApiS1.ex.invoke(a2, "foobar") } + + ch12.cancel() + ch21.cancel() + + } + } \ No newline at end of file diff --git a/src/commonTest/kotlin/parsec3/assertThrows.kt b/src/commonTest/kotlin/parsec3/assertThrows.kt new file mode 100644 index 0000000..fa8c679 --- /dev/null +++ b/src/commonTest/kotlin/parsec3/assertThrows.kt @@ -0,0 +1,21 @@ +import kotlin.test.fail + +inline fun assertThrows(f: () -> Unit): T { + try { + f() + fail("expected to throw ${T::class.simpleName} but threw nothing") + } catch (e: Throwable) { + if (e !is T) { + println("unexpected error class: ${e::class.simpleName}") + e.printStackTrace() + fail("expected to throw ${T::class.simpleName} instead ${e::class.simpleName} was thrown: $e") + } + return e + } +} + +//fun assertThrowsCode(code: String, f: () -> Unit): Unit { +// val err = assertThrows { f() } +// if( err.code != code ) +// Assert.fail("expected error code '$code' got ${err.code}") +//}