diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index e86547d..bdf835d 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Java uses: actions/setup-java@v3 with: - java-version: '17' + java-version: '11' distribution: 'corretto' - name: Cache Gradle dependencies @@ -35,4 +35,4 @@ jobs: SSH_HOST: ${{ secrets.SSH_HOST }} SSH_PASSWORD: ${{ secrets.SSH_PASSWORD }} SSH_USER: ${{ secrets.SSH_USER }} - run: ./gradlew publish -Pversion=dev-${{ env.LIB_VERSION }} + run: ./gradlew publish diff --git a/README.md b/README.md index 7699082..7cd68fc 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,63 @@ -![GitHub release (with filter)](https://img.shields.io/github/v/release/y9vad9/rsocket-kotlin-router) -![GitHub](https://img.shields.io/github/license/y9vad9/rsocket-kotlin-router) -# rsocket-kotlin-router -`rsocket-kotlin-router` is a customizable library designed to streamline and simplify routing +![GitHub release](https://img.shields.io/github/v/release/y9vad9/rsocket-kotlin-router) ![GitHub](https://img.shields.io/github/license/y9vad9/rsocket-kotlin-router) +# RSocket Router + +`rsocket-kotlin-router` is a customisable library designed to streamline and simplify routing for RSocket Kotlin server applications. This library offers a typesafe DSL for handling various routes, serving as a declarative simplified alternative to manual routing that would -otherwise, result in long-winded ternary logic or exhaustive when statements. +otherwise result in long-winded ternary logic or exhaustive when statements. + +Library provides the following features: +- [Routing Builder](#Interceptors) +- [Interceptors](#Interceptors) +- [Request Versioning](router-versioning) +- [Request Serialization](router-serialization) -## Features -### Router -It's the basic thing in the `rsocket-kotlin-router` that's responsible for managing routes, their settings, etc. You -can define it in the following way: +## How to use +First of all, you need to implement basic artifacts with routing support. For now, `rsocket-kotlin-router` +is available only at my self-hosted maven: ```kotlin -val ServerRouter = router { - router { - routeSeparator = '.' - routeProvider { metadata -> - metadata?.read(RoutingMetadata)?.tags?.first() - ?: throw RSocketError.Invalid("No routing metadata was provided") - } - - routing { // this: RoutingBuilder - // ... - } - } +repositories { + maven("https://maven.y9vad9.com") } -``` -To install it later, you can use `Router.installOn(RSocketRequestHandlerBuilder)` function: -```kotlin -fun ServerRequestHandler(router: Router): RSocket = RSocketRequestHandlerBuilder { - router.installOn(this) + +dependencies { + implementation("com.y9vad9.rsocket.router:router-core:$version") } ``` -Or you can call `router` function directly in the `RSocketRequestHandlerBuilder` context – it will automatically -install router on the given context. +> For now, it's available for JVM only, but as there is no JVM platform API used, +> new targets will be available [upon your request](https://github.com/y9vad9/rsocket-kotlin-router/issues/new). -### Routing Builder -You can define routes using bundled DSL-Builder functions: +Example of defining RSocket router: ```kotlin -fun RoutingBuilder.usersRoute(): Unit = route("users") { - // extension function that wraps RSocket `requestResponse` into `route` with given path. - requestResponse("get") { payload -> TODO() } - - // ... other +val serverRouter = router { + routeSeparator = '.' + routeProvider { metadata: ByteReadPacket? -> + metadata?.read(RoutingMetadata)?.tags?.first() + ?: throw RSocketError.Invalid("No routing metadata was provided") + } + + routing { // this: RoutingBuilder + route("authorization") { + requestResponse("register") { payload: Payload -> + // just 4 example + println(payload.data.readText()) + Payload.Empty + } + } + } } ``` -> **Note**
-> The library does not include the functionality to add routing to a `metadataPush` type of request. I am not sure -> how it should be exactly implemented (API), so your ideas are welcome. For now, I consider it a per-project responsibility. -### Interceptors -> **Warning**
-> Interceptors are experimental feature: API can be changed in the future. - -#### Preprocessors -Preprocessors are utilities that run before the routing feature applies. For cases, when you need to transform input into something or propagate -values using coroutines – you can extend [`Preprocessor.Modifier`](https://github.com/y9vad9/rsocket-kotlin-router/blob/8bace098e0a47e3cf514eec0dfb702f7e4e13591/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/interceptors/Interceptor.kt#L35) or [`Preprocessor.CoroutineContext`](https://github.com/y9vad9/rsocket-kotlin-router/blob/8bace098e0a47e3cf514eec0dfb702f7e4e13591/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/interceptors/Interceptor.kt#L27). Here's an example: + +See also what else is supported: + +
+ Interceptors +Interceptors are experimental feature: API can be changed in the future. + +Preprocessors + +Preprocessors are utilities that run before routing feature applies. For cases, when you need to transform input into something or propagate +values using coroutines – you can extend [`Preprocessor.Modifier`](https://github.com/y9vad9/rsocket-kotlin-router/blob/2a794e9a8c5d2ac53cb87ea58cfbe4a2ecfa217d/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/interceptors/Interceptor.kt#L39) or [`Preprocessor.CoroutineContext`](https://github.com/y9vad9/rsocket-kotlin-router/blob/master/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/interceptors/Interceptor.kt#L31). Here's an example: ```kotlin class MyCoroutineContextElement(val value: String): CoroutineContext.Element {...} @@ -65,7 +69,8 @@ class MyCoroutineContextPreprocessor : Preprocessor.CoroutineContext { } ``` -#### Route Interceptors +Route Interceptors + In addition to the `Preprocessors`, `rsocket-kotlin-router` also provides API to intercept specific routes: ```kotlin @OptIn(ExperimentalInterceptorsApi::class) @@ -75,9 +80,61 @@ class MyRouteInterceptor : RouteInterceptor.Modifier { } } ``` -It has the same abilities as Preprocessors. You can take a look at it [here](https://github.com/y9vad9/rsocket-kotlin-router/blob/8bace098e0a47e3cf514eec0dfb702f7e4e13591/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/interceptors/Interceptor.kt#L45). -### Testability + +Installation +```kotlin +val serverRouter = router { + preprocessors { + forCoroutineContext(MyCoroutineContextPreprocessor()) + } + + sharedInterceptors { + forModification(MyRouteInterceptor()) + } +} +``` +
+ +
+ Versioning support + +To use request versioning in your project, use the following artifact: + +```kotlin +dependencies { + // ... + implementation("com.y9vad9.rsocket.router:router-versioning-core:$version") +} +``` +For details, please refer to the [versioning guide](router-versioning/README.md). +
+ +
+ Serialization support + +To make type-safe requests with serialization/deserialization mechanisms, implement the following: + +```kotlin +dependencies { + implementation("com.y9vad9.rsocket.router:router-serialization-core:$version") + // for JSON support + implementation("com.y9vad9.rsocket.router:router-serialization-json:$version") +} +``` +For details, please refer to the [serialization guide](router-serialization/README.md). +
+ +
+ Testing + `rsocket-kotlin-router` provides ability to test your routes with `router-test` artifact: + +```kotlin +dependencies { + implementation("com.y9vad9.rsocket.router:router-test:$version") +} +``` + ```kotlin @Test fun testRoutes() { @@ -94,20 +151,12 @@ fun testRoutes() { } } ``` -You can refer to [full example](router-test/src/jvmTest/kotlin/com/y9vad9/rsocket/router/test/RouterTest.kt). -## Implementation -To implement this library, you should define the following: -```kotlin -repositories { - maven("https://maven.y9vad9.com") -} +You can refer to the [example](router-core/test/src/jvmTest/kotlin/com/y9vad9/rsocket/router/test/RouterTest.kt) for more details. +
-dependencies { - implementation("com.y9vad9.rsocket.router:router-core:$version") - // for testing - implementation("com.y9vad9.rsocket.router:router-test:$version") -} -``` -> For now, it's available for JVM only, but as there is no JVM platform API used, -> new targets will be available [upon your request](https://github.com/y9vad9/rsocket-kotlin-router/issues/new). +## Bugs and Feedback +For bugs, questions and discussions please use the [GitHub Issues](https://github.com/y9vad9/rsocket-kotlin-router/issues). + +## License +This library is licensed under [MIT License](LICENSE). Feel free to use, modify, and distribute it for any purpose. \ No newline at end of file diff --git a/build-conventions/build.gradle.kts b/build-conventions/build.gradle.kts new file mode 100644 index 0000000..1b1b586 --- /dev/null +++ b/build-conventions/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() + google() + gradlePluginPortal() +} + +kotlin { + jvmToolchain(11) +} + +dependencies { + api(libs.kotlin.plugin) + api(libs.vanniktech.maven.publish) +} \ No newline at end of file diff --git a/build-conventions/settings.gradle.kts b/build-conventions/settings.gradle.kts new file mode 100644 index 0000000..0a88534 --- /dev/null +++ b/build-conventions/settings.gradle.kts @@ -0,0 +1,14 @@ +dependencyResolutionManagement { + repositories { + mavenCentral() + google() + } + + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "conventions" \ No newline at end of file diff --git a/build-conventions/src/main/kotlin/multiplatform-module-convention.gradle.kts b/build-conventions/src/main/kotlin/multiplatform-module-convention.gradle.kts new file mode 100644 index 0000000..f85b120 --- /dev/null +++ b/build-conventions/src/main/kotlin/multiplatform-module-convention.gradle.kts @@ -0,0 +1,64 @@ +import org.jetbrains.kotlin.gradle.dsl.* + +plugins { + kotlin("multiplatform") + id("com.vanniktech.maven.publish") +} + +kotlin { + jvm() + jvmToolchain(11) + + explicitApi = ExplicitApiMode.Strict +} + +mavenPublishing { + pom { + url.set("https://github.com/y9vad9/rsocket-kotlin-router") + inceptionYear.set("2023") + + licenses { + license { + name.set("The MIT License") + url.set("https://opensource.org/licenses/MIT") + distribution.set("https://opensource.org/licenses/MIT") + } + } + + developers { + developer { + id.set("y9vad9") + name.set("Vadym Yaroshchuk") + url.set("https://github.com/y9vad9/") + } + } + + scm { + url.set("https://github.com/y9vad9/rsocket-kotlin-router") + connection.set("scm:git:git://github.com/y9vad9/rsocket-kotlin-router.git") + developerConnection.set("scm:git:ssh://git@github.com/y9vad9/rsocket-kotlin-router.git") + } + + issueManagement { + system.set("GitHub Issues") + url.set("https://github.com/y9vad9/rsocket-kotlin-router/issues") + } + } +} + +publishing { + repositories { + maven { + name = "y9vad9Maven" + + url = uri( + "sftp://${System.getenv("SSH_HOST")}:22/${System.getenv("SSH_DEPLOY_PATH")}" + ) + + credentials { + username = System.getenv("SSH_USER") + password = System.getenv("SSH_PASSWORD") + } + } + } +} \ No newline at end of file diff --git a/build-logic/publish-library-plugin/build.gradle.kts b/build-logic/publish-library-plugin/build.gradle.kts deleted file mode 100644 index 3384813..0000000 --- a/build-logic/publish-library-plugin/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -plugins { - `kotlin-dsl` -} - -group = "publish-library" -version = "SNAPSHOT" - -repositories { - gradlePluginPortal() - mavenCentral() -} - -gradlePlugin { - plugins.register("publish-library") { - id = "publish-library" - implementationClass = "com.y9vad9.maven.publish.DeployPlugin" - } -} - -dependencies { - implementation("gradle.plugin.com.github.jengelman.gradle.plugins:shadow:7.0.0") - //implementation("org.hidetake:gradle-ssh-plugin:2.11.2") -} \ No newline at end of file diff --git a/build-logic/publish-library-plugin/settings.gradle.kts b/build-logic/publish-library-plugin/settings.gradle.kts deleted file mode 100644 index 9bf07b8..0000000 --- a/build-logic/publish-library-plugin/settings.gradle.kts +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = "publish-library-plugin" \ No newline at end of file diff --git a/build-logic/publish-library-plugin/src/main/kotlin/com/y9vad9/maven/publish/DeployPlugin.kt b/build-logic/publish-library-plugin/src/main/kotlin/com/y9vad9/maven/publish/DeployPlugin.kt deleted file mode 100644 index 8e63c41..0000000 --- a/build-logic/publish-library-plugin/src/main/kotlin/com/y9vad9/maven/publish/DeployPlugin.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.y9vad9.maven.publish - -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.api.publish.PublishingExtension -import org.gradle.api.publish.maven.MavenPublication -import org.gradle.kotlin.dsl.apply -import org.gradle.kotlin.dsl.create -import org.gradle.kotlin.dsl.get -import org.gradle.kotlin.dsl.the - -class DeployPlugin : Plugin { - override fun apply(target: Project) { - target.apply(plugin = "maven-publish") - - val configuration = target.extensions.create(name = "deployLibrary") - - target.afterEvaluate { - configuration.targets.forEach { (tag, data) -> - configuration.apply { - data.host ?: return@forEach println("Skipping deployment of $tag, no host provided") - data.deployPath ?: error("`deployPath` should be defined in `deploy`") - data.componentName ?: error("`componentName` should be defined in `deploy`") - data.name ?: error("`name` should be defined in `deploy`") - data.description ?: error("`description` should be defined in `deploy`") - } - - - project.the().apply { - publications { - create("deploy to $tag") { - group = data.group ?: project.group - artifactId = data.artifactId ?: project.name - version = data.version ?: error("shouldn't be null") - - pom { - name.set(data.name ?: error("shouldn't be null")) - description.set(data.description ?: error("shouldn't be null")) - } - from(components[data.componentName ?: error("shouldn't be null")]) - } - } - repositories { - maven { - name = data.name ?: error("shouldn't be null") - version = data.version ?: error("shouldn't be null") - - url = uri( - "sftp://${data.user}@${data.host}:22/${data.deployPath}" - ) - - credentials { - username = data.user - password = data.password - } - } - } - } - } - } - } -} \ No newline at end of file diff --git a/build-logic/publish-library-plugin/src/main/kotlin/com/y9vad9/maven/publish/LibraryDeployExtension.kt b/build-logic/publish-library-plugin/src/main/kotlin/com/y9vad9/maven/publish/LibraryDeployExtension.kt deleted file mode 100644 index c7092b5..0000000 --- a/build-logic/publish-library-plugin/src/main/kotlin/com/y9vad9/maven/publish/LibraryDeployExtension.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.y9vad9.maven.publish - -import com.y9vad9.maven.publish.annotation.PublishDsl - -/** - * A DSL extension class for configuring library deployments. - */ -@PublishDsl -open class LibraryDeployExtension { - /** - * Internal mutable map that stores the deployment targets. - */ - internal val targets: MutableMap = mutableMapOf() - - /** - * Defines a new deployment target with the given [tag] and configuration [block]. - * - * Example usage: - * ``` - * ssh("maven.timemates.io") { - * host = "localhost" - * componentName = "my-library" - * group = "com.example" - * artifactId = "my-library" - * version = "1.0.0" - * deployPath = "/path/to/deployment" - * } - * ``` - * - * @param tag The tag associated with the deployment target. - * @param block The configuration block to customize the deployment target. - */ - fun ssh(tag: String, block: SshMavenDeployScope.() -> Unit) { - targets[tag] = SshMavenDeployScope().apply(block) - } -} diff --git a/build-logic/publish-library-plugin/src/main/kotlin/com/y9vad9/maven/publish/SshMavenDeployScope.kt b/build-logic/publish-library-plugin/src/main/kotlin/com/y9vad9/maven/publish/SshMavenDeployScope.kt deleted file mode 100644 index c1cdc42..0000000 --- a/build-logic/publish-library-plugin/src/main/kotlin/com/y9vad9/maven/publish/SshMavenDeployScope.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.y9vad9.maven.publish - -import com.y9vad9.maven.publish.annotation.PublishDsl - -@PublishDsl -class SshMavenDeployScope { - /** - * The hostname or IP address of the SSH server. - */ - var host: String? = null - - /** - * The name of the component being deployed. - */ - var componentName: String? = null - - /** - * The Maven group ID of the artifact. - */ - var group: String? = null - - /** - * The Maven artifact ID. - */ - var artifactId: String? = null - - /** - * The version of the artifact. - */ - var version: String? = null - - /** - * The name of the deployment. - */ - var name: String? = null - - /** - * The description of the deployment. - */ - var description: String? = null - - /** - * The path on the remote server where the artifact should be deployed. - */ - var deployPath: String? = null - - /** - * The SSH username for authentication (optional). - */ - var user: String? = null - - /** - * The SSH password for authentication (optional). - */ - var password: String? = null -} diff --git a/build-logic/publish-library-plugin/src/main/kotlin/com/y9vad9/maven/publish/annotation/PublishDsl.kt b/build-logic/publish-library-plugin/src/main/kotlin/com/y9vad9/maven/publish/annotation/PublishDsl.kt deleted file mode 100644 index 23f2a2b..0000000 --- a/build-logic/publish-library-plugin/src/main/kotlin/com/y9vad9/maven/publish/annotation/PublishDsl.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.y9vad9.maven.publish.annotation - -@DslMarker -annotation class PublishDsl \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..a31762e --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) apply false +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0510339..878cae2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,9 +12,12 @@ rsocket-server = { module = "io.rsocket.kotlin:rsocket-ktor-server", version.ref rsocket-test = { module = "io.rsocket.kotlin:rsocket-test", version.ref = "rsocket" } rsocket-server-websockets = { module = "io.rsocket.kotlin:rsocket-transport-ktor-websocket-server", version.ref = "rsocket" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlin-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +publish-library = { module = "publish-library:publish-library", version.require = "SNAPSHOT" } +vanniktech-maven-publish = { module = "com.vanniktech.maven.publish:com.vanniktech.maven.publish.gradle.plugin", version.require = "0.25.3" } [plugins] kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -library-publish = { id = "publish-library", version.require = "SNAPSHOT" } +multiplatform-module-convention = { id = "multiplatform-module-convention", version.require = "SNAPSHOT" } diff --git a/router-core/build.gradle.kts b/router-core/build.gradle.kts index f69a0be..6624e03 100644 --- a/router-core/build.gradle.kts +++ b/router-core/build.gradle.kts @@ -1,37 +1,27 @@ -import org.jetbrains.kotlin.gradle.dsl.* - plugins { - alias(libs.plugins.kotlin.multiplatform) - alias(libs.plugins.library.publish) + id(libs.plugins.multiplatform.module.convention.get().pluginId) + `maven-publish` } -kotlin { - jvm() +group = "com.y9vad9.rsocket.router" +version = System.getenv("LIB_VERSION") ?: "SNAPSHOT" - explicitApi = ExplicitApiMode.Strict -} dependencies { commonMainImplementation(libs.rsocket.server) - commonMainImplementation(libs.rsocket.server.websockets) commonMainImplementation(libs.kotlinx.coroutines) } -deployLibrary { - ssh(tag = "routerCore") { - host = System.getenv("SSH_HOST") - user = System.getenv("SSH_USER") - password = System.getenv("SSH_PASSWORD") - deployPath = System.getenv("SSH_DEPLOY_PATH") - - group = "com.y9vad9.rsocket.router" - componentName = "kotlin" - artifactId = "router-core" - name = "router-core" - - description = "Kotlin RSocket library for routing" +mavenPublishing { + coordinates( + groupId = "com.y9vad9.rsocket.router", + artifactId = "router-core", + version = System.getenv("LIB_VERSION") ?: return@mavenPublishing, + ) - version = System.getenv("LIB_VERSION") + pom { + name.set("Router Core") + description.set("Kotlin RSocket library for routing management.") } } \ No newline at end of file diff --git a/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/Route.kt b/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/Route.kt index 4977f6d..3368bed 100644 --- a/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/Route.kt +++ b/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/Route.kt @@ -23,28 +23,28 @@ public data class Route internal constructor( @property:ExperimentalInterceptorsApi val interceptors: List, ) { - public suspend fun fireAndForgetOrThrow(payload: Payload) { + public suspend fun fireAndForget(payload: Payload) { processPayload(payload) { payload -> requests.fireAndForget?.invoke(payload) ?: throwInvalidRequestOnRoute("fireAndForget") } } - public suspend fun requestResponseOrThrow(payload: Payload): Payload { + public suspend fun requestResponse(payload: Payload): Payload { return processPayload(payload) { payload -> requests.requestResponse?.invoke(payload) ?: throwInvalidRequestOnRoute("requestResponse") } } - public suspend fun requestStreamOrThrow(payload: Payload): Flow { + public suspend fun requestStream(payload: Payload): Flow { return processPayload(payload) { payload -> requests.requestStream?.invoke(payload) ?: throwInvalidRequestOnRoute("requestStream") } } - public suspend fun requestChannelOrThrow( + public suspend fun requestChannel( initPayload: Payload, payloads: Flow, ): Flow = processPayload(initPayload) { initialPayload -> diff --git a/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/Router.kt b/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/Router.kt index 422922c..0fe6301 100644 --- a/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/Router.kt +++ b/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/Router.kt @@ -71,28 +71,28 @@ public fun Router.installOn(handlerBuilder: RSocketRequestHandlerBuilder): Unit requestResponse { payload -> payload.intercept(preprocessors) { routeAtOrFail(getRoutePathFromMetadata(it.metadata)) - .requestResponseOrThrow(it) + .requestResponse(it) } } requestStream { payload -> payload.intercept(preprocessors) { routeAtOrFail(getRoutePathFromMetadata(it.metadata)) - .requestStreamOrThrow(it) + .requestStream(it) } } requestChannel { initPayload, payloads -> initPayload.intercept(preprocessors) { routeAtOrFail(getRoutePathFromMetadata(it.metadata)) - .requestChannelOrThrow(it, payloads) + .requestChannel(it, payloads) } } fireAndForget { payload -> payload.intercept(preprocessors) { routeAtOrFail(getRoutePathFromMetadata(it.metadata)) - .fireAndForgetOrThrow(it) + .fireAndForget(it) } } } diff --git a/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/builders/RouterBuilder.kt b/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/builders/RouterBuilder.kt index 15c2ea9..6f881fa 100644 --- a/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/builders/RouterBuilder.kt +++ b/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/builders/RouterBuilder.kt @@ -1,6 +1,5 @@ package com.y9vad9.rsocket.router.builders -import io.ktor.utils.io.core.* import com.y9vad9.rsocket.router.Router import com.y9vad9.rsocket.router.RouterImpl import com.y9vad9.rsocket.router.annotations.ExperimentalInterceptorsApi @@ -12,6 +11,7 @@ import com.y9vad9.rsocket.router.interceptors.Preprocessor import com.y9vad9.rsocket.router.interceptors.RouteInterceptor import com.y9vad9.rsocket.router.interceptors.builder.PreprocessorsBuilder import com.y9vad9.rsocket.router.interceptors.builder.RouteInterceptorsBuilder +import io.ktor.utils.io.core.* @RouterDsl public class RouterBuilder @InternalRouterApi constructor() { @@ -45,14 +45,12 @@ public class RouterBuilder @InternalRouterApi constructor() { */ @ExperimentalInterceptorsApi public fun preprocessors(builder: PreprocessorsBuilder.() -> Unit) { - require(preprocessors == null) { "preprocessors should be defined once." } - preprocessors = PreprocessorsBuilder().apply(builder).build() + preprocessors = (preprocessors ?: emptyList()) + PreprocessorsBuilder().apply(builder).build() } @ExperimentalInterceptorsApi public fun sharedInterceptors(builder: RouteInterceptorsBuilder.() -> Unit) { - require(sharedInterceptors == null) { "sharedInterceptors should be defined once." } - sharedInterceptors = RouteInterceptorsBuilder().apply(builder).build() + sharedInterceptors = (sharedInterceptors ?: emptyList()) + RouteInterceptorsBuilder().apply(builder).build() } public fun routeProvider(provider: suspend (metadata: ByteReadPacket?) -> String) { diff --git a/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/builders/RoutingBuilder.kt b/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/builders/RoutingBuilder.kt index 77f6df8..9693914 100644 --- a/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/builders/RoutingBuilder.kt +++ b/router-core/src/commonMain/kotlin/com.y9vad9.rsocket.router/builders/RoutingBuilder.kt @@ -1,5 +1,7 @@ package com.y9vad9.rsocket.router.builders +import com.y9vad9.rsocket.router.Route +import com.y9vad9.rsocket.router.annotations.ExperimentalRouterApi import io.ktor.utils.io.core.* import io.rsocket.kotlin.ExperimentalMetadataApi import io.rsocket.kotlin.RSocketError @@ -8,9 +10,12 @@ import io.rsocket.kotlin.metadata.RoutingMetadata import io.rsocket.kotlin.metadata.read import com.y9vad9.rsocket.router.annotations.RouterDsl import com.y9vad9.rsocket.router.router +import io.ktor.server.routing.* /** * Interface for building routes for RSocket requests. + * + * **Not stable for inheritance.** */ @RouterDsl public interface RoutingBuilder { diff --git a/router-core/test/build.gradle.kts b/router-core/test/build.gradle.kts new file mode 100644 index 0000000..3d2159b --- /dev/null +++ b/router-core/test/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + id(libs.plugins.multiplatform.module.convention.get().pluginId) +} + +group = "com.y9vad9.rsocket.router" +version = System.getenv("LIB_VERSION") ?: "SNAPSHOT" + +dependencies { + commonMainImplementation(libs.rsocket.server) + + commonMainImplementation(projects.routerCore) + + commonMainImplementation(libs.kotlinx.coroutines) + commonTestImplementation(libs.kotlin.test) +} + +mavenPublishing { + coordinates( + groupId = "com.y9vad9.rsocket.router", + artifactId = "router-test", + version = System.getenv("LIB_VERSION") ?: return@mavenPublishing, + ) + + pom { + name.set("Router Test") + description.set("Library for testing rsocket routes.") + } +} \ No newline at end of file diff --git a/router-test/src/commonMain/kotlin/com/y9vad9/rsocket/router/test/Validators.kt b/router-core/test/src/commonMain/kotlin/com/y9vad9/rsocket/router/test/Validators.kt similarity index 91% rename from router-test/src/commonMain/kotlin/com/y9vad9/rsocket/router/test/Validators.kt rename to router-core/test/src/commonMain/kotlin/com/y9vad9/rsocket/router/test/Validators.kt index 258e2a5..f8b6436 100644 --- a/router-test/src/commonMain/kotlin/com/y9vad9/rsocket/router/test/Validators.kt +++ b/router-core/test/src/commonMain/kotlin/com/y9vad9/rsocket/router/test/Validators.kt @@ -33,19 +33,19 @@ public fun Route.assertHasPreprocessor(ofClass: KClass) { } public suspend fun Route.fireAndForgetOrAssert(payload: Payload): Unit = try { - fireAndForgetOrThrow(payload) + fireAndForget(payload) } catch (e: Throwable) { throw AssertionError("Failed to execute fire-and-forget method on `$path` route.", e) } public suspend fun Route.requestResponseOrAssert(payload: Payload): Payload = try { - requestResponseOrThrow(payload) + requestResponse(payload) } catch (e: Throwable) { throw AssertionError("Failed to execute request-response method on `$path` route.", e) } public suspend fun Route.requestStreamOrAssert(payload: Payload): Flow = try { - requestStreamOrThrow(payload) + requestStream(payload) } catch (e: Throwable) { throw AssertionError("Failed to execute request-stream method on `$path` route.", e) } @@ -54,10 +54,14 @@ public suspend fun Route.requestChannelOrAssert( initPayload: Payload, payloads: Flow, ): Flow = try { - requestChannelOrThrow(initPayload, payloads) + requestChannel(initPayload, payloads) } catch (e: Throwable) { throw AssertionError("Failed to execute request-channel method on `$path` route.", e) } public fun Router.routeAtOrAssert(path: String): Route = routeAt(path) - ?: throw AssertionError("Route at `$path` path is not found.") \ No newline at end of file + ?: throw AssertionError("Route at `$path` path is not found.") + +public fun Router.assertHasRoute(path: String) { + routeAtOrAssert(path) +} \ No newline at end of file diff --git a/router-test/src/jvmTest/kotlin/com/y9vad9/rsocket/router/test/RouterTest.kt b/router-core/test/src/jvmTest/kotlin/com/y9vad9/rsocket/router/test/RouterTest.kt similarity index 100% rename from router-test/src/jvmTest/kotlin/com/y9vad9/rsocket/router/test/RouterTest.kt rename to router-core/test/src/jvmTest/kotlin/com/y9vad9/rsocket/router/test/RouterTest.kt diff --git a/router-serialization/README.md b/router-serialization/README.md new file mode 100644 index 0000000..75c1d75 --- /dev/null +++ b/router-serialization/README.md @@ -0,0 +1,45 @@ +# Requests Serialization + +Often, we have a type-safe contract specifying what we accept and what we return in requests. Serializing this data can be challenging, especially when dealing with different formats or migrating to a new one. Even if it's not the case, defining your own wrappers or extensions from scratch takes time and, as mentioned before, can lead to potential problems in the future. That's why `rsocket-kotlin-router` provides a ready-to-use pragmatic serialization system. + +> **Warning**
+> This feature is experimental, and migration steps might be required in the future. + +## How to Use + +### Implementation + +First, add the necessary dependencies: + +```kotlin +dependencies { + implementation("com.y9vad9.rsocket.router:router-serialization-core:$version") + // for JSON support + implementation("com.y9vad9.rsocket.router:router-serialization-json:$version") +} +``` +### Installation +To add serialization to your requests, install the required ContentSerializer in your router. For example, using JsonContentSerializer: +```kotlin +val router = router { + // ... + serialization { JsonContentSerializer() } + // ... +} +``` +### Usage +You can use the bundled extensions as follows: +```kotlin +routing { + route("authorization") { + requestResponse("register") { foo: Foo -> + return@requestResponse Bar(/* ... */) + } + // other types of the requests have the same extensions + } +} +``` +### Custom formats +To add support for other existing formats, you can simply extend `ContentSerializer`. You can take a look at +[`JsonContentSerializer`](json/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/json/JsonContentSerializer.kt) +as an example. \ No newline at end of file diff --git a/router-serialization/core/build.gradle.kts b/router-serialization/core/build.gradle.kts new file mode 100644 index 0000000..e6c3abd --- /dev/null +++ b/router-serialization/core/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id(libs.plugins.multiplatform.module.convention.get().pluginId) +} + +dependencies { + commonMainImplementation(libs.rsocket.server) + + commonMainImplementation(projects.routerCore) +} + +mavenPublishing { + coordinates( + groupId = "com.y9vad9.rsocket.router", + artifactId = "router-serialization-core", + version = System.getenv("LIB_VERSION") ?: return@mavenPublishing + ) + + pom { + name.set("Router Serialization Core") + description.set( + """ + Kotlin RSocket library for type-safe serializable routing. Provides extensions for routing builder and + abstraction to serialize/deserialize data. + """.trimIndent() + ) + } +} \ No newline at end of file diff --git a/router-serialization/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/ContentSerializer.kt b/router-serialization/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/ContentSerializer.kt new file mode 100644 index 0000000..218dd8a --- /dev/null +++ b/router-serialization/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/ContentSerializer.kt @@ -0,0 +1,45 @@ +package com.y9vad9.rsocket.router.serialization + +import io.ktor.utils.io.core.* +import kotlin.reflect.KClass +import kotlin.reflect.KType +import kotlin.reflect.typeOf + +public interface ContentSerializer { + /** + * Serializes the given [packet] into a specific type [T]. + * + * @param kClass The [KClass] representing the type [T]. + * @param packet The [ByteReadPacket] to be serialized. + * @throws SerializationException if an error occurs during serialization. + */ + public fun decode(kType: KType, packet: ByteReadPacket): T + + /** + * Deserializes the given value of type T into a ByteReadPacket. + * + * @param kClass The class of the value to be deserialized. + * @param value The value to be deserialized. + * @return The deserialized value as a ByteReadPacket. + */ + public fun encode(kType: KType, value: T): ByteReadPacket +} + +/** + * Reified version of [ContentSerializer.decode]. Uses the reified type [T] to automatically infer its KClass. + * + * @param packet The [ByteReadPacket] to be serialized. + */ +public inline fun ContentSerializer.decode(packet: ByteReadPacket): T { + return decode(typeOf(), packet) +} + +/** + * Reified version of [ContentSerializer.encode]. Uses the reified type [T] to automatically infer its KClass. + * + * @param value The value to be deserialized. + * @return The deserialized value as a ByteReadPacket. + */ +public inline fun ContentSerializer.encode(value: T): ByteReadPacket { + return encode(typeOf(), value) +} \ No newline at end of file diff --git a/router-serialization/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/DeclarableRoutingBuilderExt.kt b/router-serialization/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/DeclarableRoutingBuilderExt.kt new file mode 100644 index 0000000..a665cbe --- /dev/null +++ b/router-serialization/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/DeclarableRoutingBuilderExt.kt @@ -0,0 +1,114 @@ +@file:OptIn( + ExperimentalInterceptorsApi::class, + InternalRouterSerializationApi::class, +) + +package com.y9vad9.rsocket.router.serialization + +import com.y9vad9.rsocket.router.annotations.ExperimentalInterceptorsApi +import com.y9vad9.rsocket.router.builders.DeclarableRoutingBuilder +import com.y9vad9.rsocket.router.serialization.annotations.InternalRouterSerializationApi +import com.y9vad9.rsocket.router.serialization.preprocessor.SerializationProvider +import io.rsocket.kotlin.payload.Payload +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlin.reflect.typeOf + +/** + * Executes a request-response operation with the given payload. + * + * @param T the type of the input payload. + * @param R the type of the output payload. + * @param block the suspend lambda that performs the request-response operation. + */ +public inline fun DeclarableRoutingBuilder.requestResponse( + crossinline block: suspend (T) -> R, +): Unit = requestResponse { payload -> + val contentSerializer = SerializationProvider.getFromCoroutineContext() + + block(contentSerializer.decode(typeOf(), payload.data)) + .let { result -> contentSerializer.encode(typeOf(), result) } + .let { data -> Payload(data = data) } +} + +/** + * Executes a streaming request with the given payload. + * + * @param T the type of the payload to be decoded. + * @param R the type of the result to be encoded. + * @param block the suspend lambda function that takes the decoded payload and returns a Flow of results. + * + * @return Unit + */ +public inline fun DeclarableRoutingBuilder.requestStream( + crossinline block: suspend (T) -> Flow, +): Unit = requestStream { payload -> + val contentSerializer = SerializationProvider.getFromCoroutineContext() + + block(contentSerializer.decode(typeOf(), payload.data)) + .map { result -> Payload(data = contentSerializer.encode(typeOf(), result)) } +} + +/** + * Executes a given suspend block without expecting any return value. + * + * @param block The suspend block to be executed. + * @param T The type of the payload to be passed to the block. + */ +public inline fun DeclarableRoutingBuilder.fireAndForget( + crossinline block: suspend (T) -> Unit, +): Unit = fireAndForget { payload -> + val contentSerializer = SerializationProvider.getFromCoroutineContext() + + block(contentSerializer.decode(payload.data)) +} + +/** + * Sends a request channel to the router and provides a response channel. + * This method is used to send a series of request elements (`T`) to the router, + * receive a series of response elements (`R`) from the router, and complete the channel. + * + * @param T the type of the initial request element and subsequent request elements + * @param R the type of the response elements + * @param block the suspending lambda function that processes the incoming request elements and returns the response elements + */ +public inline fun DeclarableRoutingBuilder.requestChannel( + crossinline block: suspend (initial: T, Flow) -> Flow, +): Unit = requestChannel { initial: Payload, payloads: Flow -> + val contentSerializer = SerializationProvider.getFromCoroutineContext() + + val init = contentSerializer.decode(initial.data) + val mappedPayloads: Flow = payloads.map { contentSerializer.decode(it.data) } + + block(init, mappedPayloads) + .map { result -> Payload(data = contentSerializer.encode(result)) } +} + + +public inline fun DeclarableRoutingBuilder.requestResponse( + path: String, + crossinline block: suspend (T) -> R, +): Unit = route(path) { + requestResponse(block) +} + +public inline fun DeclarableRoutingBuilder.requestStream( + path: String, + crossinline block: suspend (T) -> Flow, +): Unit = route(path) { + requestStream(block) +} + +public inline fun DeclarableRoutingBuilder.fireAndForget( + path: String, + crossinline block: suspend (T) -> Unit, +): Unit = route(path) { + fireAndForget(block) +} + +public inline fun DeclarableRoutingBuilder.requestChannel( + path: String, + crossinline block: suspend (initial: T, Flow) -> Flow, +): Unit = route(path) { + requestChannel(block) +} \ No newline at end of file diff --git a/router-serialization/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/annotations/InternalRouterSerializationApi.kt b/router-serialization/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/annotations/InternalRouterSerializationApi.kt new file mode 100644 index 0000000..cd18830 --- /dev/null +++ b/router-serialization/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/annotations/InternalRouterSerializationApi.kt @@ -0,0 +1,4 @@ +package com.y9vad9.rsocket.router.serialization.annotations + +@RequiresOptIn(level = RequiresOptIn.Level.ERROR) +public annotation class InternalRouterSerializationApi \ No newline at end of file diff --git a/router-serialization/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/context/SerializationContext.kt b/router-serialization/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/context/SerializationContext.kt new file mode 100644 index 0000000..dc4e069 --- /dev/null +++ b/router-serialization/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/context/SerializationContext.kt @@ -0,0 +1,12 @@ +package com.y9vad9.rsocket.router.serialization.context + +import com.y9vad9.rsocket.router.serialization.ContentSerializer +import kotlin.coroutines.CoroutineContext + +internal data class SerializationContext( + val contentSerializer: ContentSerializer, +) : CoroutineContext.Element { + override val key: CoroutineContext.Key<*> = Key + + companion object Key : CoroutineContext.Key +} \ No newline at end of file diff --git a/router-serialization/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/preprocessor/SerializationProvider.kt b/router-serialization/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/preprocessor/SerializationProvider.kt new file mode 100644 index 0000000..fb06ff9 --- /dev/null +++ b/router-serialization/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/preprocessor/SerializationProvider.kt @@ -0,0 +1,64 @@ +package com.y9vad9.rsocket.router.serialization.preprocessor + +import com.y9vad9.rsocket.router.annotations.ExperimentalInterceptorsApi +import com.y9vad9.rsocket.router.builders.RouterBuilder +import com.y9vad9.rsocket.router.interceptors.Preprocessor +import com.y9vad9.rsocket.router.serialization.ContentSerializer +import com.y9vad9.rsocket.router.serialization.annotations.InternalRouterSerializationApi +import com.y9vad9.rsocket.router.serialization.context.SerializationContext +import io.rsocket.kotlin.payload.Payload +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.coroutineContext + +@ExperimentalInterceptorsApi +public abstract class SerializationProvider : Preprocessor.CoroutineContext { + public companion object { + /** + * Retrieves the [ContentSerializer] from the coroutine context. + * + * @return The [ContentSerializer] obtained from the coroutine context. + * @throws IllegalStateException If the [ContentSerializer] was not provided or the method was called from + * an illegal context. + */ + @InternalRouterSerializationApi + public suspend fun getFromCoroutineContext(): ContentSerializer { + return coroutineContext[SerializationContext]?.contentSerializer + // 1) you shouldn't call this function yourself unless you use it to define your own extensions + // that should be dependent on it. + // 2) if you didn't call it yourself, probably you need to register `SerializationProvider` by + // putting it in the preprocessors or by using `serialization` function in `RoutingBuilder`. + // note: if function wasn't called by you intentionally and `SerializationProvider` is already + // registered, but inside `test` artifact you should provide content serializer to context using `asContextElement`. + ?: error("ContentSerializer wasn't provided or call happened from illegal context") + } + + public suspend fun asContextElement(serializer: ContentSerializer): CoroutineContext = + coroutineContext + SerializationContext(serializer) + } + + public abstract fun provide(coroutineContext: CoroutineContext, payload: Payload): ContentSerializer + + final override fun intercept(coroutineContext: CoroutineContext, input: Payload): CoroutineContext { + return coroutineContext + SerializationContext(provide(coroutineContext, input)) + } +} + +public fun RouterBuilder.serialization(block: () -> ContentSerializer) { + serialization { _, _ -> block() } +} + +@OptIn(ExperimentalInterceptorsApi::class) +public fun RouterBuilder.serialization(block: (CoroutineContext, Payload) -> ContentSerializer) { + preprocessors { + forCoroutineContext( + object : SerializationProvider() { + override fun provide( + coroutineContext: CoroutineContext, + payload: Payload, + ): ContentSerializer { + return block(coroutineContext, payload) + } + } + ) + } +} \ No newline at end of file diff --git a/router-serialization/json/build.gradle.kts b/router-serialization/json/build.gradle.kts new file mode 100644 index 0000000..af211f8 --- /dev/null +++ b/router-serialization/json/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + id(libs.plugins.multiplatform.module.convention.get().pluginId) +} + +dependencies { + commonMainImplementation(libs.rsocket.server) + commonMainImplementation(libs.kotlinx.serialization) + + commonMainImplementation(projects.routerSerialization.core) + commonMainImplementation(projects.routerCore) +} + +mavenPublishing { + coordinates( + groupId = "com.y9vad9.rsocket.router", + artifactId = "router-serialization-json", + version = System.getenv("LIB_VERSION") ?: return@mavenPublishing + ) + + pom { + name.set("Router Serialization (Json)") + description.set( + """ + Kotlin RSocket library for type-safe serializable routing. Provides JSON implementation of `ContentSerializer`. + """.trimIndent() + ) + } +} \ No newline at end of file diff --git a/router-serialization/json/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/json/JsonContentSerializer.kt b/router-serialization/json/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/json/JsonContentSerializer.kt new file mode 100644 index 0000000..ea8eda3 --- /dev/null +++ b/router-serialization/json/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/json/JsonContentSerializer.kt @@ -0,0 +1,52 @@ +package com.y9vad9.rsocket.router.serialization.json + +import com.y9vad9.rsocket.router.serialization.ContentSerializer +import io.ktor.util.* +import io.ktor.utils.io.core.* +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.serializer +import kotlinx.serialization.serializerOrNull +import kotlin.reflect.KType + +/** + * A content serializer that uses JSON format for encoding and decoding data. + * + * @property json The JSON object to use for serialization and deserialization. + */ +public class JsonContentSerializer(private val json: Json = Json) : ContentSerializer { + /** + * Decodes a serialized object from a ByteReadPacket using JSON serialization. + * + * @param kType The KType representing the type of the object to be decoded. + * @param packet The ByteReadPacket containing the serialized object data. + * @return The deserialized object of type T. + */ + @Suppress("UNCHECKED_CAST") + @OptIn(ExperimentalSerializationApi::class) + override fun decode(kType: KType, packet: ByteReadPacket): T { + return json.decodeFromStream( + (json.serializersModule.serializerOrNull(kType) ?: serializer()) as KSerializer, + packet.asStream() + ) + } + + /** + * Encodes the given value of type T to a ByteReadPacket using the JSON serialization. + * + * @param kType The Kotlin type of the value. + * @param value The value to encode. + * @return The encoded value as a ByteReadPacket. + */ + @Suppress("UNCHECKED_CAST") + override fun encode(kType: KType, value: T): ByteReadPacket { + return ByteReadPacket( + json.encodeToString( + (json.serializersModule.serializerOrNull(kType) ?: serializer()) as KSerializer, + value, + ).toByteArray() + ) + } +} \ No newline at end of file diff --git a/router-serialization/test/build.gradle.kts b/router-serialization/test/build.gradle.kts new file mode 100644 index 0000000..67b59c9 --- /dev/null +++ b/router-serialization/test/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + id(libs.plugins.multiplatform.module.convention.get().pluginId) + alias(libs.plugins.kotlinx.serialization) +} + +dependencies { + commonMainImplementation(libs.rsocket.server) + commonMainImplementation(libs.kotlinx.serialization) + + + commonMainImplementation(projects.routerCore) + commonMainImplementation(projects.routerCore.test) + commonMainImplementation(projects.routerSerialization.core) + + jvmTestImplementation(projects.routerSerialization.json) + jvmTestImplementation(libs.kotlin.test) +} + +mavenPublishing { + coordinates( + groupId = "com.y9vad9.rsocket.router", + artifactId = "router-serialization-test", + version = System.getenv("LIB_VERSION") ?: return@mavenPublishing + ) + + pom { + name.set("Router Serialization Testing") + description.set( + """ + Kotlin RSocket library for testing type-safe serializable routes. Experimental: can be dropped or changed + at any time. + """.trimIndent() + ) + } +} \ No newline at end of file diff --git a/router-serialization/test/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/test/RouterExt.kt b/router-serialization/test/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/test/RouterExt.kt new file mode 100644 index 0000000..5147e26 --- /dev/null +++ b/router-serialization/test/src/commonMain/kotlin/com/y9vad9/rsocket/router/serialization/test/RouterExt.kt @@ -0,0 +1,84 @@ +@file:OptIn( + ExperimentalInterceptorsApi::class, InternalRouterSerializationApi::class, + InternalRouterSerializationApi::class, +) + +package com.y9vad9.rsocket.router.serialization.test + +import com.y9vad9.rsocket.router.Route +import com.y9vad9.rsocket.router.annotations.ExperimentalInterceptorsApi +import com.y9vad9.rsocket.router.serialization.annotations.InternalRouterSerializationApi +import com.y9vad9.rsocket.router.serialization.decode +import com.y9vad9.rsocket.router.serialization.encode +import com.y9vad9.rsocket.router.serialization.preprocessor.SerializationProvider +import com.y9vad9.rsocket.router.test.fireAndForgetOrAssert +import com.y9vad9.rsocket.router.test.requestChannelOrAssert +import com.y9vad9.rsocket.router.test.requestResponseOrAssert +import com.y9vad9.rsocket.router.test.requestStreamOrAssert +import io.ktor.server.routing.* +import io.ktor.utils.io.core.* +import io.rsocket.kotlin.payload.Payload +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +public suspend inline fun Route.requestResponseOrAssert( + data: T, + metadata: ByteReadPacket? = null, +): R { + val contentSerializer = SerializationProvider.getFromCoroutineContext() + + return requestResponseOrAssert( + Payload( + data = contentSerializer.encode(data), + metadata = metadata, + ) + ).let { contentSerializer.decode(it.data) } +} + +public suspend inline fun Route.fireAndForgetOrAssert( + data: T, + metadata: ByteReadPacket? = null, +) { + val contentSerializer = SerializationProvider.getFromCoroutineContext() + + return fireAndForgetOrAssert( + Payload( + data = contentSerializer.encode(data), + metadata = metadata, + ) + ) +} + +public suspend inline fun Route.requestStreamOrAssert( + data: T, + metadata: ByteReadPacket? = null, +): Flow { + val contentSerializer = SerializationProvider.getFromCoroutineContext() + + return requestStreamOrAssert( + Payload( + data = contentSerializer.encode(data), + metadata = metadata, + ) + ).let { flow -> + flow.map { contentSerializer.decode(it.data) } + } +} + +public suspend inline fun Route.requestChannelOrAssert( + initial: T, + payloads: Flow, + metadata: ByteReadPacket? = null, +): Flow { + val contentSerializer = SerializationProvider.getFromCoroutineContext() + + return requestChannelOrAssert( + initPayload = Payload( + data = contentSerializer.encode(initial), + metadata = metadata, + ), + payloads = payloads.map { Payload(contentSerializer.encode(it)) }, + ).let { flow -> + flow.map { contentSerializer.decode(it.data) } + } +} \ No newline at end of file diff --git a/router-serialization/test/src/jvmTest/kotlin/com/y9vad9/rsocket/router/serialization/SerializableRouterTest.kt b/router-serialization/test/src/jvmTest/kotlin/com/y9vad9/rsocket/router/serialization/SerializableRouterTest.kt new file mode 100644 index 0000000..81ad50c --- /dev/null +++ b/router-serialization/test/src/jvmTest/kotlin/com/y9vad9/rsocket/router/serialization/SerializableRouterTest.kt @@ -0,0 +1,56 @@ +package com.y9vad9.rsocket.router.serialization + +import com.y9vad9.rsocket.router.annotations.ExperimentalInterceptorsApi +import com.y9vad9.rsocket.router.router +import com.y9vad9.rsocket.router.serialization.json.JsonContentSerializer +import com.y9vad9.rsocket.router.serialization.preprocessor.SerializationProvider +import com.y9vad9.rsocket.router.serialization.preprocessor.serialization +import com.y9vad9.rsocket.router.serialization.test.requestResponseOrAssert +import com.y9vad9.rsocket.router.test.requestResponseOrAssert +import com.y9vad9.rsocket.router.test.routeAtOrAssert +import io.rsocket.kotlin.ExperimentalMetadataApi +import io.rsocket.kotlin.metadata.RoutingMetadata +import io.rsocket.kotlin.metadata.RoutingMetadata.Reader.read +import io.rsocket.kotlin.metadata.read +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals + +class SerializableRouterTest { + @Serializable + private data class Foo(val bar: Int) + + @Serializable + private data class Bar(val foo: Int) + + private val router = router { + serialization { _, _ -> JsonContentSerializer(Json) } + routeProvider { _ -> TODO() } + + routing { + route("test") { + requestResponse { + Bar(it.bar) + } + } + } + } + + @OptIn(ExperimentalInterceptorsApi::class) + @Test + fun `check serialization`() { + runBlocking { + withContext(SerializationProvider.asContextElement(JsonContentSerializer(Json))) { + val result = router.routeAtOrAssert("test") + .requestResponseOrAssert( + data = Foo(0), + ) + + assertEquals(result.foo, 0) + } + } + } +} \ No newline at end of file diff --git a/router-test/build.gradle.kts b/router-test/build.gradle.kts deleted file mode 100644 index f97b640..0000000 --- a/router-test/build.gradle.kts +++ /dev/null @@ -1,40 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.* - -plugins { - alias(libs.plugins.kotlin.multiplatform) - alias(libs.plugins.library.publish) -} - -kotlin { - jvm() - - explicitApi = ExplicitApiMode.Strict -} - -dependencies { - commonMainImplementation(libs.rsocket.server) - commonMainImplementation(libs.rsocket.server.websockets) - - commonMainImplementation(projects.routerCore) - - commonMainImplementation(libs.kotlinx.coroutines) - commonTestImplementation(libs.kotlin.test) -} - -deployLibrary { - ssh(tag = "routerTest") { - host = System.getenv("SSH_HOST") - user = System.getenv("SSH_USER") - password = System.getenv("SSH_PASSWORD") - deployPath = System.getenv("SSH_DEPLOY_PATH") - - group = "com.y9vad9.rsocket.router" - componentName = "kotlin" - artifactId = "router-test" - name = "router-test" - - description = "Library for testing rsocket routes" - - version = System.getenv("LIB_VERSION") - } -} \ No newline at end of file diff --git a/router-test/src/commonMain/kotlin/com/y9vad9/rsocket/router/test/TestBuilders.kt b/router-test/src/commonMain/kotlin/com/y9vad9/rsocket/router/test/TestBuilders.kt deleted file mode 100644 index e33f7ab..0000000 --- a/router-test/src/commonMain/kotlin/com/y9vad9/rsocket/router/test/TestBuilders.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.y9vad9.rsocket.router.test - -import io.rsocket.kotlin.RSocket -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext - -public object EmptyRSocket : RSocket { - override val coroutineContext: CoroutineContext - get() = EmptyCoroutineContext -} - -public fun emptyRSocket(): RSocket = EmptyRSocket \ No newline at end of file diff --git a/router-versioning/README.md b/router-versioning/README.md new file mode 100644 index 0000000..0775c1f --- /dev/null +++ b/router-versioning/README.md @@ -0,0 +1,61 @@ +# Route Versioning + +When a product grows and evolves, dealing with backward and forward compatibility becomes essential. For these purposes, the library provides necessary wrappers around `router-core` with versioning support. + +> **Warning** +> This feature is experimental; migration steps might be required in the future. + +## How It Works + +Every type of request with the `router-versioning-core` artifact now has its extension with a DSL builder for versioning: + +```kotlin +val router = { + routeProvider { /*...*/ } + versionProvider { coroutineContext, payload -> Version(/* ... */) } + + routing { + route("authorization") { + requestResponseV("register") { + // available from version 1 up to 2 + version(1) { payload -> + TODO() + } + + // available from version 2 and onwards + version(2) { payload -> + TODO() + } + } + } + } +} +``` +> **Note**
+> As for semantic versioning, you can also specify minor version for each new request within one major release if +> you need using `version(version: Version, block: suspend (T) -> R)`. + +## Implementation +To implement this feature, add it to your dependencies as follows: +```kotlin +dependencies { + implementation("com.y9vad9.rsocket.router:router-versioning-core:$version") +} +``` + +## Serialization support +To use [serialization feature](../router-serialization), implement the following dependency: +```kotlin +dependencies { + implementation("com.y9vad9.rsocket.router:router-versioning-serialization:$version") +} +``` +### Example +Here's example of how you can define type-safe requests with versioning support: +```kotlin +requestResponseV("register") { + version(1) { foo: Foo -> + Bar(/* ... */) + } +} +``` \ No newline at end of file diff --git a/router-versioning/core/README.md b/router-versioning/core/README.md new file mode 100644 index 0000000..375b7dd --- /dev/null +++ b/router-versioning/core/README.md @@ -0,0 +1,83 @@ +# `router-versioned-cpre` + +This artifact is an auxiliary to the base RSocket Kotlin Router library. It enhances your RSocket +services with routing layer by adding support for semantic versioning. + +## Version Providers + +This artifact introduces the concept of Version Providers. This is encapsulated by +the `VersionPreprocessor` class used to extract and process version details from the incoming RSocket payload. +You must override this class to provide a custom method to fetch version details, typically fetched from the metadata of +the payload. + +## Routing and Versioning + +`router-versioned-core` provides an intuitive DSL for routing and versioning, simplifying the user interaction model. +Let's take a brief look at how to use it: + +```kotlin +@OptIn(ExperimentalInterceptorsApi::class) +public class VersionProviderPreprocessor : VersionPreprocessor() { + override fun version(payload: Payload): Version { + TODO() + } +} + +public val Version.Companion.V1_0: Version by lazy { Version(1, 0) } +public val Version.Companion.V2_0: Version by lazy { Version(2, 0) } + +val router: Router = router { + preprocessors { + forCoroutineContext(MyVersionPreprocessor()) + } + + routing { + route("auth") { + // short version + requestResponseV("start") { + version(1) { payload -> + // handle requests for version "1.0" + Payload.Empty + } + version(2) { payload -> + // handle requests for version "2.0" + Payload.Empty + } + } + // or longer version + requestStreamVersioned("confirm") { + // you can specify version up to minor and patch + version(Version.V1_0) { payload -> + // handle requests for version "1.0" + flow(Payload.Empty) + } + + // you can specify version up to minor and patch + version(Version.V2_0) { payload -> + // handle requests for version "2.0" + flow(Payload.Empty) + } + } + } + } +} +``` +> **Note**
+> But, you shouldn't use patch version for versioning your requests. It's used only as annotation. +> +> `minor` should also be used only as an annotation and only for versioning new requests ideally. Changing +> contract of specific request should always happen on new major version. Read more about [semantic versioning](https://semver.org/). + +In this example, the DSL allows developers to define different handlers for each available version of their endpoints. +This way, clients running on different versions can coexist and interact with the service in a consistent fashion. + +## Implementation +```kotlin +repositories { + maven("https://maven.y9vad9.com") +} + +dependencies { + implementation("com.y9vad9.rsocket.router:router-versioned-core:$version") +} +``` \ No newline at end of file diff --git a/router-versioning/core/build.gradle.kts b/router-versioning/core/build.gradle.kts new file mode 100644 index 0000000..fd74b04 --- /dev/null +++ b/router-versioning/core/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + id(libs.plugins.multiplatform.module.convention.get().pluginId) +} + +dependencies { + commonMainImplementation(libs.rsocket.server) + commonMainImplementation(projects.routerCore) +} + +mavenPublishing { + coordinates( + groupId = "com.y9vad9.rsocket.router", + artifactId = "router-versioning-core", + version = System.getenv("LIB_VERSION") ?: return@mavenPublishing + ) + + pom { + name.set("Router Versioning") + description.set( + """ + Kotlin RSocket library for version-safe routing. Provides semantic versioning mechanism for ensuring + backward and forward compatibility. + """.trimIndent() + ) + } +} \ No newline at end of file diff --git a/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/DeclarableRoutingBuilderExt.kt b/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/DeclarableRoutingBuilderExt.kt new file mode 100644 index 0000000..06f8183 --- /dev/null +++ b/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/DeclarableRoutingBuilderExt.kt @@ -0,0 +1,171 @@ +package com.y9vad9.rsocket.router.versioning + +import com.y9vad9.rsocket.router.builders.DeclarableRoutingBuilder +import com.y9vad9.rsocket.router.versioning.annotations.VersioningDsl +import com.y9vad9.rsocket.router.versioning.builders.VersioningBuilder +import com.y9vad9.rsocket.router.versioning.preprocessor.VersionPreprocessor +import io.rsocket.kotlin.payload.Payload +import kotlinx.coroutines.flow.Flow + +/** + * Shortened variant of [requestResponseVersioned]. + */ +@VersioningDsl +public fun DeclarableRoutingBuilder.requestResponseV( + path: String, + block: VersioningBuilder.() -> Unit, +): Unit = requestResponseVersioned(path, block) + +public fun DeclarableRoutingBuilder.requestResponseVersioned( + path: String, + block: VersioningBuilder.() -> Unit, +): Unit = route(path) { + requestResponseVersioned(block) +} + +/** + * Creates a request-response endpoint with versioning support. + * + * @param path The URL path for the endpoint. + * @param block A lambda function that configures the endpoint using a [VersioningBuilder]. + * + * @throws IllegalStateException if the [VersionPreprocessor] is not registered. + */ +@VersioningDsl +public fun DeclarableRoutingBuilder.requestResponseVersioned( + block: VersioningBuilder.() -> Unit, +) { + val versionedRequest = VersioningBuilder().apply(block).build() + + requestResponse { payload -> + versionedRequest.execute(payload) + } +} + +@VersioningDsl +public fun DeclarableRoutingBuilder.requestResponseV( + block: VersioningBuilder.() -> Unit, +): Unit = requestResponseVersioned(block) + +/** + * Shortened variant of [requestStreamVersioned]. + */ +@VersioningDsl +public fun DeclarableRoutingBuilder.requestStreamV( + path: String, + block: VersioningBuilder>.() -> Unit, +): Unit = requestStreamVersioned(path, block) + + +public fun DeclarableRoutingBuilder.requestStreamVersioned( + path: String, + block: VersioningBuilder>.() -> Unit, +): Unit = route(path) { + requestStreamVersioned(block) +} + +/** + * Creates a request-stream endpoint with versioning support. + * + * @param path The URL path for the endpoint. + * @param block A lambda function that configures the endpoint using a [VersioningBuilder]. + * + * @throws IllegalStateException if the [VersionPreprocessor] is not registered. + */ +@VersioningDsl +public fun DeclarableRoutingBuilder.requestStreamVersioned( + block: VersioningBuilder>.() -> Unit, +) { + val versionedRequest = VersioningBuilder>().apply(block).build() + + requestStream { payload -> + versionedRequest.execute(payload) + } +} + +@VersioningDsl +public fun DeclarableRoutingBuilder.requestStreamV( + block: VersioningBuilder>.() -> Unit, +): Unit = requestStreamVersioned(block) + +/** + * Shortened variant of [fireAndForgetVersioned]. + */ +@VersioningDsl +public fun DeclarableRoutingBuilder.fireAndForgetV( + path: String, + block: VersioningBuilder.() -> Unit, +): Unit = fireAndForgetVersioned(path, block) + + +public fun DeclarableRoutingBuilder.fireAndForgetVersioned( + path: String, + block: VersioningBuilder.() -> Unit, +): Unit = route(path) { + fireAndForgetVersioned(block) +} + +/** + * Creates a fireAndForget endpoint with versioning support. + * + * @param path The URL path for the endpoint. + * @param block A lambda function that configures the endpoint using a [VersioningBuilder]. + * + * @throws IllegalStateException if the [VersionPreprocessor] is not registered. + */ +@VersioningDsl +public fun DeclarableRoutingBuilder.fireAndForgetVersioned( + block: VersioningBuilder.() -> Unit, +) { + val versionedRequest = VersioningBuilder().apply(block).build() + + fireAndForget { payload -> + versionedRequest.execute(payload) + } +} + +@VersioningDsl +public fun DeclarableRoutingBuilder.fireAndForgetV( + block: VersioningBuilder.() -> Unit, +): Unit = fireAndForgetVersioned(block) + + +/** + * Shortened variant of [requestChannelVersioned]. + */ +@VersioningDsl +public fun DeclarableRoutingBuilder.requestChannelV( + path: String, + block: VersioningBuilder>.() -> Unit, +): Unit = requestChannelVersioned(path, block) + +public fun DeclarableRoutingBuilder.requestChannelVersioned( + path: String, + block: VersioningBuilder>.() -> Unit, +): Unit = route(path) { + requestChannelVersioned(block) +} + +/** + * Creates a request-channel endpoint with versioning support. + * + * @param path The URL path for the endpoint. + * @param block A lambda function that configures the endpoint using a [VersioningBuilder]. + * + * @throws IllegalStateException if the [VersionPreprocessor] is not registered. + */ +@VersioningDsl +public fun DeclarableRoutingBuilder.requestChannelVersioned( + block: VersioningBuilder>.() -> Unit, +) { + val versionedRequest = VersioningBuilder>().apply(block).build() + + requestChannel { initPayload, payloads -> + versionedRequest.execute(PayloadStream(initPayload, payloads)) + } +} + +@VersioningDsl +public fun DeclarableRoutingBuilder.requestChannelV( + block: VersioningBuilder>.() -> Unit, +): Unit = requestChannelVersioned(block) \ No newline at end of file diff --git a/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/Version.kt b/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/Version.kt new file mode 100644 index 0000000..776152e --- /dev/null +++ b/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/Version.kt @@ -0,0 +1,82 @@ +package com.y9vad9.rsocket.router.versioning + +import com.y9vad9.rsocket.router.annotations.ExperimentalInterceptorsApi +import com.y9vad9.rsocket.router.versioning.preprocessor.VersionPreprocessor +import kotlin.coroutines.coroutineContext + +/** + * Represents a version number. + * + * @param double The numerical value of the version. + * @throws IllegalArgumentException if the version is negative. + */ +public data class Version(public val major: Int, public val minor: Int, public val patch: Int = 0) : Comparable { + init { + require(major >= 0) { "Major version cannot be negative" } + require(minor >= 0) { "Minor version cannot be negative" } + require(patch >= 0) { "Patch version cannot be negative" } + } + + public companion object { + /** + * Represents the first version. In meaning of versioning, it means that + * we accept request / route from any version. + */ + public val ZERO: Version = Version(0, 0, 0) + /** + * Represents the latest version. + * + * In meaning of versioning, it means that request has no max version + * and it's actual. + * + * @see Version + */ + public val INDEFINITE: Version = Version(Int.MAX_VALUE, Int.MAX_VALUE, Int.MAX_VALUE) + } + + /** + * Compares this version with the specified version. + * + * @param other the version to be compared + * @return a negative integer, zero, or a positive integer if this version is less than, equal to, or greater than + * the specified version + */ + override fun compareTo(other: Version): Int { + return when { + this.major != other.major -> this.major - other.major + this.minor != other.minor -> this.minor - other.minor + else -> this.patch - other.patch + } + } +} + +/** + * Creates a closed range of versions starting from this version and ending at another version. + * + * @param another The ending version of the range. + * @return A closed range of versions from this version to the specified ending version. + * @throws IllegalArgumentException If the ending version is negative. + */ +public infix fun Version.until(another: Version): ClosedRange { + return when { + another.patch > 0 -> this .. another.copy(patch = patch - 1) + another.minor > 0 -> this .. another.copy(minor = minor - 1) + another.major > 0 -> this .. another.copy(major = major - 1) + else -> error("Unable to create `until` range – version cannot be negative.") + } +} + + +/** + * Retrieves the version of the requester. + * + * @return The version of the requester if available. + * @throws IllegalStateException if the version cannot be retrieved. This can happen if the function is called from an illegal context or if the preprocessor wasn't installed. + * + * @since 1.0.0 + * @experimental This API is experimental and subject to change in future versions. + */ +@OptIn(ExperimentalInterceptorsApi::class) +internal suspend fun getRequesterVersion(): Version = + coroutineContext[VersionPreprocessor.VersionElement]?.version + ?: error("Unable to retrieve version: Preprocessor wasn't installed.") \ No newline at end of file diff --git a/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/VersionRequirements.kt b/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/VersionRequirements.kt new file mode 100644 index 0000000..8961abc --- /dev/null +++ b/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/VersionRequirements.kt @@ -0,0 +1,21 @@ +package com.y9vad9.rsocket.router.versioning + +/** + * Class representing the requirements for a version range. + * + * @property firstAcceptableVersion The first acceptable version in the range. + * @property lastAcceptableVersion The last acceptable version in the range. + */ +public data class VersionRequirements( + val firstAcceptableVersion: Version, + val lastAcceptableVersion: Version, +) { + /** + * Checks if the given version satisfies the acceptable version range. + * + * @param version The version to be checked against the acceptable version range. + * @return true if the given version is within the acceptable range, false otherwise. + */ + public fun satisfies(version: Version): Boolean = + version in firstAcceptableVersion until lastAcceptableVersion +} \ No newline at end of file diff --git a/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/VersionedRequest.kt b/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/VersionedRequest.kt new file mode 100644 index 0000000..8262133 --- /dev/null +++ b/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/VersionedRequest.kt @@ -0,0 +1,64 @@ +package com.y9vad9.rsocket.router.versioning + +import io.rsocket.kotlin.RSocketError +import kotlinx.coroutines.flow.Flow + +/** + * A sealed class representing versioned requests. + * @param T The input type of the request. + * @param R The result type of the request. + */ +internal sealed class VersionedRequest { + /** + * Executes the given input and returns the result. + * + * @param input The input to be executed. + * @return The result of executing the input. + */ + abstract suspend fun execute(input: T): R + + /** + * Executes a single conditional request that checks if the input version satisfies the version requirements. + * + * @param T the type of the input parameter. + * @param R the type of the return value. + * @property function the suspend function to be executed. + * @property versionRequirements the version requirements that need to be satisfied. + */ + class SingleConditional( + val function: suspend (T) -> R, + val versionRequirements: VersionRequirements + ) : VersionedRequest() { + override suspend fun execute(input: T): R { + val version = getRequesterVersion() + + if (!versionRequirements.satisfies(version)) + throw RSocketError.Rejected("Request is not available for your API version.") + return function(input) + } + } + + /** + * A class that provides multiple conditional execution based on API version requirements. + * + * @param T The input type of the request. + * @param R The result type of the request. + * @property variants A list of pairs that associate version requirements with suspended functions that take input of type T and return output of type R. + */ + data class MultipleConditional( + val variants: List R>> + ) : VersionedRequest() { + override suspend fun execute(input: T): R { + val version = getRequesterVersion() + + return variants.firstOrNull { (requirement, _) -> + requirement.satisfies(version) + }?.second?.invoke(input) ?: throw RSocketError.Rejected("Request is not available for your API version.") + } + } +} + +public data class PayloadStream( + val initPayload: io.rsocket.kotlin.payload.Payload, + val payloads: Flow, +) \ No newline at end of file diff --git a/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/annotations/ExperimentalVersioningApi.kt b/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/annotations/ExperimentalVersioningApi.kt new file mode 100644 index 0000000..7ce3295 --- /dev/null +++ b/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/annotations/ExperimentalVersioningApi.kt @@ -0,0 +1,4 @@ +package com.y9vad9.rsocket.router.versioning.annotations + +@RequiresOptIn(message = "Experimental API. Might be removed.", level = RequiresOptIn.Level.ERROR) +public annotation class ExperimentalVersioningApi \ No newline at end of file diff --git a/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/annotations/VersioningDsl.kt b/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/annotations/VersioningDsl.kt new file mode 100644 index 0000000..490915e --- /dev/null +++ b/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/annotations/VersioningDsl.kt @@ -0,0 +1,4 @@ +package com.y9vad9.rsocket.router.versioning.annotations + +@DslMarker +public annotation class VersioningDsl \ No newline at end of file diff --git a/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/builders/VersioningBuilder.kt b/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/builders/VersioningBuilder.kt new file mode 100644 index 0000000..cd763b4 --- /dev/null +++ b/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/builders/VersioningBuilder.kt @@ -0,0 +1,67 @@ +package com.y9vad9.rsocket.router.versioning.builders + +import com.y9vad9.rsocket.router.versioning.Version +import com.y9vad9.rsocket.router.versioning.VersionRequirements +import com.y9vad9.rsocket.router.versioning.VersionedRequest +import com.y9vad9.rsocket.router.versioning.until + +/** + * Builder class used for versioning requests. + * @param T The type of the request. + * @param R The type of the response. + */ +public class VersioningBuilder internal constructor() { + private var versionedRequest: VersionedRequest? = null + + /** + * Updates the versioned request based on the given version. + * + * @param version The new version to be applied. + * @param block The coroutine block that will be executed for the given version. + */ + public fun version(version: Version, block: suspend (T) -> R): Unit { + versionedRequest = when (val versionedRequest = versionedRequest) { + null -> { + VersionedRequest.SingleConditional( + function = block, + VersionRequirements(firstAcceptableVersion = version, lastAcceptableVersion = Version.INDEFINITE) + ) + } + + is VersionedRequest.SingleConditional -> { + VersionedRequest.MultipleConditional( + variants = listOf( + versionedRequest.versionRequirements.copy( + lastAcceptableVersion = (versionedRequest.versionRequirements.firstAcceptableVersion until version).endInclusive + ) to versionedRequest.function, + VersionRequirements(version, Version.INDEFINITE) to block, + ) + ) + } + + is VersionedRequest.MultipleConditional -> { + versionedRequest.copy( + variants = versionedRequest.variants.mapIndexed { index, (requirements, function) -> + if (index == versionedRequest.variants.lastIndex) + requirements.copy( + lastAcceptableVersion = (requirements.firstAcceptableVersion until version).endInclusive, + ) to function + else requirements to function + } + ) + } + } + } + + internal fun build(): VersionedRequest = versionedRequest ?: error("No version was specified.") +} + +/** + * Adds a version to the VersioningBuilder. + * + * @param major The major version number. + * @param block The block of code to be executed for the given version. + * @return Unit + */ +public fun VersioningBuilder.version(major: Int, block: suspend (T) -> R): Unit = + version(Version(major, 0), block) \ No newline at end of file diff --git a/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/preprocessor/VersionPreprocessor.kt b/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/preprocessor/VersionPreprocessor.kt new file mode 100644 index 0000000..b06ed18 --- /dev/null +++ b/router-versioning/core/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/preprocessor/VersionPreprocessor.kt @@ -0,0 +1,29 @@ +package com.y9vad9.rsocket.router.versioning.preprocessor + +import com.y9vad9.rsocket.router.annotations.ExperimentalInterceptorsApi +import com.y9vad9.rsocket.router.interceptors.Preprocessor +import com.y9vad9.rsocket.router.versioning.Version +import io.rsocket.kotlin.payload.Payload +import kotlin.coroutines.CoroutineContext + +@ExperimentalInterceptorsApi +public abstract class VersionPreprocessor : Preprocessor.CoroutineContext { + internal data class VersionElement(val version: Version) : CoroutineContext.Element { + companion object Key : CoroutineContext.Key + + override val key: CoroutineContext.Key<*> + get() = Key + } + + /** + * Retrieves the version from the specified payload. + * + * @param payload The payload containing the version information. + * @return The version extracted from the payload. + */ + public abstract fun version(payload: Payload): Version + + final override fun intercept(coroutineContext: CoroutineContext, input: Payload): CoroutineContext { + return coroutineContext + VersionElement(version(input)) + } +} \ No newline at end of file diff --git a/router-versioning/serialization/build.gradle.kts b/router-versioning/serialization/build.gradle.kts new file mode 100644 index 0000000..b8641d7 --- /dev/null +++ b/router-versioning/serialization/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + id(libs.plugins.multiplatform.module.convention.get().pluginId) +} + +dependencies { + commonMainImplementation(libs.rsocket.server) + commonMainImplementation(libs.kotlinx.serialization) + + commonMainImplementation(projects.routerVersioning.core) + commonMainImplementation(projects.routerSerialization.core) + + commonMainImplementation(projects.routerCore) +} + +mavenPublishing { + coordinates( + groupId = "com.y9vad9.rsocket.router", + artifactId = "router-versioning-serialization", + version = System.getenv("LIB_VERSION") ?: return@mavenPublishing + ) + + pom { + name.set("Router Versioning Serialization Adapter") + description.set(""" + Kotlin RSocket library for supporting serialization mechanism in versioned routes. + """.trimIndent() + ) + } +} \ No newline at end of file diff --git a/router-versioning/serialization/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/serialization/DeclarableRoutingBuilderExt.kt b/router-versioning/serialization/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/serialization/DeclarableRoutingBuilderExt.kt new file mode 100644 index 0000000..dc65848 --- /dev/null +++ b/router-versioning/serialization/src/commonMain/kotlin/com/y9vad9/rsocket/router/versioning/serialization/DeclarableRoutingBuilderExt.kt @@ -0,0 +1,71 @@ +@file:OptIn(ExperimentalInterceptorsApi::class, InternalRouterSerializationApi::class) + +package com.y9vad9.rsocket.router.versioning.serialization + +import com.y9vad9.rsocket.router.annotations.ExperimentalInterceptorsApi +import com.y9vad9.rsocket.router.serialization.annotations.InternalRouterSerializationApi +import com.y9vad9.rsocket.router.serialization.decode +import com.y9vad9.rsocket.router.serialization.encode +import com.y9vad9.rsocket.router.serialization.preprocessor.SerializationProvider +import com.y9vad9.rsocket.router.versioning.PayloadStream +import com.y9vad9.rsocket.router.versioning.Version +import com.y9vad9.rsocket.router.versioning.builders.VersioningBuilder +import io.rsocket.kotlin.payload.Payload +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +@JvmName("versionRequestResponse") +public inline fun VersioningBuilder.version( + version: Version, + crossinline block: suspend (T) -> R, +) { + version(version) { payload -> + val contentSerializer = SerializationProvider.getFromCoroutineContext() + + block(contentSerializer.decode(payload.data)) + .let { contentSerializer.encode(it) } + .let { Payload(it) } + } +} + +@JvmName("versionFireAndForget") +public inline fun VersioningBuilder.version( + version: Version, + crossinline block: suspend (T) -> Unit, +) { + version(version) { payload -> + val contentSerializer = SerializationProvider.getFromCoroutineContext() + + block(contentSerializer.decode(payload.data)) + } +} + +@JvmName("versionRequestStream") +public inline fun VersioningBuilder>.version( + version: Version, + crossinline block: suspend (T) -> Flow, +) { + version(version) { payload -> + val contentSerializer = SerializationProvider.getFromCoroutineContext() + + block(contentSerializer.decode(payload.data)) + .map { Payload(contentSerializer.encode(it)) } + } +} + +@JvmName("versionRequestChannel") +@OptIn(InternalRouterSerializationApi::class) +public inline fun VersioningBuilder>.version( + version: Version, + crossinline block: suspend (initial: T, payloads: Flow) -> Flow, +) { + version(version) { payload -> + val contentSerializer = SerializationProvider.getFromCoroutineContext() + + val initial: T = contentSerializer.decode(payload.initPayload.data) + val payloads: Flow = payload.payloads.map { contentSerializer.decode(it.data) } + + block(initial, payloads) + .map { Payload(contentSerializer.encode(it)) } + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 6d983c9..cb32f0f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,6 +20,15 @@ pluginManagement { rootProject.name = "rsocket-kotlin-router" -includeBuild("build-logic/publish-library-plugin") +includeBuild("build-conventions") -include(":router-core", ":router-test") \ No newline at end of file +include(":router-core", ":router-core:test") +include( + ":router-versioning:core", + ":router-versioning:serialization", +) +include( + ":router-serialization:core", + ":router-serialization:json", + ":router-serialization:test", +) \ No newline at end of file