diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml
index 504cff2..93da70f 100644
--- a/.github/workflows/publish-release.yml
+++ b/.github/workflows/publish-release.yml
@@ -35,4 +35,4 @@ jobs:
REPOSILITE_SECRET: ${{ secrets.REPOSILITE_SECRET }}
GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }}
GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }}
- run: ./gradlew publishAllPublicationsToTimeMatesReleasesRepository publishPlugins --no-daemon -Pversion=${{ env.LIB_VERSION }} -Pgradle.publish.key=${{ env.GRADLE_PUBLISH_KEY }} -Pgradle.publish.secret=${{ env.GRADLE_PUBLISH_SECRET }}
+ run: ./gradlew publishAllPublicationsToTimeMatesReleasesRepository --no-daemon -Pversion=${{ env.LIB_VERSION }} -Pgradle.publish.key=${{ env.GRADLE_PUBLISH_KEY }} -Pgradle.publish.secret=${{ env.GRADLE_PUBLISH_SECRET }}
diff --git a/README.md b/README.md
index a988754..53ab1fb 100644
--- a/README.md
+++ b/README.md
@@ -1,89 +1,29 @@
-![Maven metadata URL](https://img.shields.io/maven-metadata/v?metadataUrl=https%3A%2F%2Fmaven.timemates.org%2Freleases%2Forg%2Ftimemates%2Frsproto%2Fclient-core%2Fmaven-metadata.xml)
-![GitHub issues](https://img.shields.io/github/issues/timemates/rsproto)
-![GitHub closed pull requests](https://img.shields.io/github/issues-pr-closed/timemates/rsproto)
-![GitHub License](https://img.shields.io/github/license/timemates/rsproto)
-# RSProto
-RSProto is a framework that designed to provide ability to expose your API as RPC Services. It facilitates the creation of gRPC-like services from .proto files through code generation. The framework also provides essential core components for both server and client.
+![Maven metadata URL](https://img.shields.io/maven-metadata/v?metadataUrl=https%3A%2F%2Fmaven.timemates.org%2Freleases%2Forg%2Ftimemates%2Frrpc-kotlin%2Fclient-core%2Fmaven-metadata.xml)
+![GitHub issues](https://img.shields.io/github/issues/timemates/rrpc-kotlin)
+![GitHub closed pull requests](https://img.shields.io/github/issues-pr-closed/timemates/rrpc-kotlin)
+![GitHub License](https://img.shields.io/github/license/timemates/rrpc-kotlin)
+# rRPC Kotlin
+
+rRPC is a framework designed to provide an ability to expose your API as RPC Services.
+It facilitates the creation of gRPC-like services from .proto files through code generation.
+The framework also provides essential core components for both server and client.
> **Warning**
-> This project is experimental, be ready for bugs and possible changes.
+Under prototyping, currently unavailable to use.
## Features
- **Gradle Plugin**: `.proto` to RSocket code generator (both client and server).
- **Server Core** (JVM only): Interceptors, Instances API and bridge between Ktor and library.
- **Client Core** (JVM, Web, iOS): Metadata and basic interface-markers.
-> **Known issues**
-> As RSocket does not natively support client-only streaming – it's not supported by the
-> code-generator.
-
-## Getting Started
-First of all, you should have the following repository:
-```kotlin
-repositories {
- maven("https://maven.timemates.org/releases")
-}
-```
-
-### Core components
-Before using builtin generation from `.proto` to RSocket services, you should add the following dependencies:
-```kotlin
-dependencies {
- // for server
- implementation("org.timemates.rsproto:server-core:$version")
- // for client
- commonMainImplementation("org.timemates.rsproto:client-core:$version")
-
- // for server & client
- commonMainImplementation("org.timemates.rsproto:common-core:$version")
-}
-```
-#### Server initialization
-To configure your server, you can use ready-to-use build-bridges between Ktor and RSProto:
-```kotlin
-fun Application.configureServer() {
- routing {
- rSocketServer("/rsocket") {
- interceptor(MyInterceptor())
- service(MyService())
-
- instances {
- protobuf {
- encodeDefaults = true
- }
- }
- }
- }
-}
-```
-#### Client initialization
-Clients are generated by the code-generation Gradle plugin. To use them you need instance of `rsocket` and `protobuf`:
-```kotlin
-val apiService = TestServiceApi(rsocket, protobuf)
-apiService.sayHello()
-```
+## Documentation
-### Code-generation plugin
-To implement code-generation plugin in your buildscript, add the following:
-```kotlin
-plugins {
- id("org.timemates.rsproto") version "$version"
-}
-```
-And use it inside your buildscript:
-```kotlin
-rsproto {
- protoSourcePath.set("src/main/proto")
- generationOutputPath.set("generated/proto-generator/src/commonMain")
- clientGeneration.set(true)
- serverGeneration.set(true)
-}
-```
+You can learn more about the library in official documentation – [https://rrpc.timemates.org](https://rrpc.timemates.org/section-starting-page.html).
## Feedback
For bugs, questions and discussions please use
-the [GitHub Issues](https://github.com/timemates/rsproto/issues).
+the [GitHub Issues](https://github.com/timemates/rrpc-kotlin/issues).
## License
diff --git a/build-conventions/src/main/kotlin/jvm-library-convention.gradle.kts b/build-conventions/src/main/kotlin/jvm-library-convention.gradle.kts
index 9070e84..37b287d 100644
--- a/build-conventions/src/main/kotlin/jvm-library-convention.gradle.kts
+++ b/build-conventions/src/main/kotlin/jvm-library-convention.gradle.kts
@@ -1,4 +1,8 @@
plugins {
id("jvm-convention")
id("library-convention")
+}
+
+kotlin {
+ explicitApi()
}
\ No newline at end of file
diff --git a/build-conventions/src/main/kotlin/library-convention.gradle.kts b/build-conventions/src/main/kotlin/library-convention.gradle.kts
index 6e11394..d6c7f26 100644
--- a/build-conventions/src/main/kotlin/library-convention.gradle.kts
+++ b/build-conventions/src/main/kotlin/library-convention.gradle.kts
@@ -4,7 +4,7 @@ plugins {
mavenPublishing {
pom {
- url.set("https://github.com/timemates/rsproto")
+ url.set("https://github.com/RRpc/rrpc-kotlin")
inceptionYear.set("2023")
licenses {
@@ -24,29 +24,37 @@ mavenPublishing {
}
scm {
- url.set("https://github.com/timemates/rsproto")
- connection.set("scm:git:git://github.com/timemates/rsproto.git")
- developerConnection.set("scm:git:ssh://git@github.com/timemates/rsproto.git")
+ url.set("https://github.com/RRpc/rrpc-kotlin")
+ connection.set("scm:git:git://github.com/RRpc/rrpc-kotlin.git")
+ developerConnection.set("scm:git:ssh://git@github.com/RRpc/rrpc-kotlin.git")
}
issueManagement {
system.set("GitHub Issues")
- url.set("https://github.com/timemates/rsproto/issues")
+ url.set("https://github.com/RRpc/rrpc-kotlin/issues")
}
}
}
publishing {
repositories {
- maven {
- name = "timeMatesReleases"
+ if (project.hasProperty("publish-reposilite")) {
+ maven {
+ val isDev = version.toString().contains("dev")
- url = uri("https://maven.timemates.org/releases")
+ name = if (isDev) "timeMatesDev" else "timeMatesReleases"
+ url = if (isDev) uri("https://maven.timemates.org/dev") else uri("https://maven.timemates.org/releases")
- credentials {
- username = System.getenv("REPOSILITE_USER")
- password = System.getenv("REPOSILITE_SECRET")
+ credentials {
+ username = System.getenv("REPOSILITE_USER")
+ password = System.getenv("REPOSILITE_SECRET")
+ }
}
+ } else {
+ logger.log(
+ LogLevel.INFO,
+ "Publishing is disabled: publish-locally or publish-reposilite parameter should be used to specify publication destination."
+ )
}
}
}
\ No newline at end of file
diff --git a/build-conventions/src/main/kotlin/multiplatform-convention.gradle.kts b/build-conventions/src/main/kotlin/multiplatform-convention.gradle.kts
index 6cf1d52..5fa29d2 100644
--- a/build-conventions/src/main/kotlin/multiplatform-convention.gradle.kts
+++ b/build-conventions/src/main/kotlin/multiplatform-convention.gradle.kts
@@ -1,3 +1,5 @@
+import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
+
plugins {
kotlin("multiplatform")
}
@@ -5,4 +7,9 @@ plugins {
kotlin {
jvm()
jvmToolchain(11)
+
+ @OptIn(ExperimentalKotlinGradlePluginApi::class)
+ compilerOptions {
+ optIn.add("kotlinx.serialization.ExperimentalSerializationApi")
+ }
}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index cd10eff..617eb14 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,3 +1,4 @@
plugins {
id(libs.plugins.conventions.multiplatform.library.get().pluginId) apply false
-}
\ No newline at end of file
+ alias(libs.plugins.kotlinx.serialization) apply false
+}
diff --git a/client-core/build.gradle.kts b/client-core/build.gradle.kts
deleted file mode 100644
index 59ddeaa..0000000
--- a/client-core/build.gradle.kts
+++ /dev/null
@@ -1,36 +0,0 @@
-plugins {
- id(libs.plugins.conventions.multiplatform.library.get().pluginId)
- alias(libs.plugins.kotlinx.serialization)
-}
-
-group = "org.timemates.rsproto"
-version = System.getenv("LIB_VERSION") ?: "SNAPSHOT"
-
-dependencies {
- commonMainApi(libs.rsocket.client)
- commonMainApi(libs.kotlinx.serialization.proto)
- commonMainApi(projects.commonCore)
-}
-
-kotlin {
- js(IR) {
- browser()
- nodejs()
- }
- iosArm64()
- iosX64()
- iosSimulatorArm64()
-}
-
-mavenPublishing {
- coordinates(
- groupId = "org.timemates.rsproto",
- artifactId = "client-core",
- version = System.getenv("LIB_VERSION") ?: return@mavenPublishing,
- )
-
- pom {
- name.set("RSProto Client Core")
- description.set("Multiplatform Kotlin core library for RSProto clients.")
- }
-}
\ No newline at end of file
diff --git a/client-core/src/commonMain/kotlin/org/timemates/rsproto/client/RSProtoClient.kt b/client-core/src/commonMain/kotlin/org/timemates/rsproto/client/RSProtoClient.kt
deleted file mode 100644
index d0fc572..0000000
--- a/client-core/src/commonMain/kotlin/org/timemates/rsproto/client/RSProtoClient.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package org.timemates.rsproto.client
-
-/**
- * Interface-marker for the clients that are generated by `rsproto`.
- */
-public interface RSProtoClient
\ No newline at end of file
diff --git a/client/core/build.gradle.kts b/client/core/build.gradle.kts
new file mode 100644
index 0000000..e289a46
--- /dev/null
+++ b/client/core/build.gradle.kts
@@ -0,0 +1,46 @@
+plugins {
+ id(libs.plugins.conventions.multiplatform.library.get().pluginId)
+ alias(libs.plugins.kotlinx.serialization)
+}
+
+group = "org.timemates.rrpc"
+version = System.getenv("LIB_VERSION") ?: "SNAPSHOT"
+
+dependencies {
+ dependencies {
+ // -- Project --
+ commonMainImplementation(projects.common.core)
+
+ // -- RSocket --
+ commonMainApi(libs.rsocket.client)
+
+ // -- Test --
+ jvmTestImplementation(libs.kotlin.test)
+ jvmTestImplementation(libs.mockk)
+ }
+
+}
+
+kotlin {
+ jvm()
+// js(IR) {
+// browser()
+// nodejs()
+// }
+// iosArm64()
+// iosX64()
+// iosSimulatorArm64()
+}
+
+mavenPublishing {
+ coordinates(
+ groupId = "org.timemates.rrpc",
+ artifactId = "client-core",
+ version = System.getenv("LIB_VERSION") ?: return@mavenPublishing,
+ )
+
+ pom {
+ name.set("RRpc Client Core")
+ description.set("Multiplatform Kotlin core library for RRpc clients.")
+ }
+}
diff --git a/client/core/src/commonMain/kotlin/org/timemates/rrpc/client/ClientRequestHandler.kt b/client/core/src/commonMain/kotlin/org/timemates/rrpc/client/ClientRequestHandler.kt
new file mode 100644
index 0000000..93b6dfb
--- /dev/null
+++ b/client/core/src/commonMain/kotlin/org/timemates/rrpc/client/ClientRequestHandler.kt
@@ -0,0 +1,254 @@
+@file:OptIn(ExperimentalSerializationApi::class, ExperimentalInterceptorsApi::class)
+@file:Suppress("UNCHECKED_CAST")
+
+package org.timemates.rrpc.client
+
+import io.ktor.utils.io.core.*
+import io.rsocket.kotlin.payload.Payload
+import kotlinx.coroutines.flow.*
+import kotlinx.serialization.*
+import org.timemates.rrpc.*
+import org.timemates.rrpc.annotations.ExperimentalInterceptorsApi
+import org.timemates.rrpc.annotations.InternalRRpcAPI
+import org.timemates.rrpc.client.config.RRpcClientConfig
+import org.timemates.rrpc.instances.protobuf
+import org.timemates.rrpc.interceptors.InterceptorContext
+import org.timemates.rrpc.metadata.ClientMetadata
+import org.timemates.rrpc.metadata.ServerMetadata
+import org.timemates.rrpc.options.OptionsWithValue
+
+/**
+ * Handles client requests by managing metadata and data serialization, making the appropriate calls, and processing the responses.
+ * Utilizes interceptors to modify request and response data.
+ *
+ * @property config The configuration for the RRpc client.
+ */
+@InternalRRpcAPI
+public class ClientRequestHandler(
+ private val config: RRpcClientConfig,
+) {
+ private val protobuf get() = config.instances.protobuf ?: error("Protobuf instance should always be present.")
+
+ /**
+ * Makes a request-response call.
+ *
+ * @param metadata Metadata to be sent with the request.
+ * @param data Data to be sent with the request.
+ * @param options Options for the request.
+ * @param serializationStrategy Serialization strategy for the data.
+ * @param deserializationStrategy Deserialization strategy for the response data.
+ * @return The response data.
+ */
+ @OptIn(InternalRRpcAPI::class)
+ public suspend fun requestResponse(
+ metadata: ClientMetadata,
+ data: T,
+ options: OptionsWithValue,
+ serializationStrategy: SerializationStrategy,
+ deserializationStrategy: DeserializationStrategy,
+ ): R = with(config) {
+ val requestContext = interceptors.runInputInterceptors(
+ Single(data),
+ metadata,
+ options,
+ instances,
+ )
+
+ val finalMetadata = requestContext?.metadata ?: metadata
+ val finalData = (requestContext?.data?.requireSingle() as? T) ?: data
+
+ val request = Payload(
+ ByteReadPacket(protobuf.encodeToByteArray(serializationStrategy, value = finalData)),
+ ByteReadPacket(protobuf.encodeToByteArray(ClientMetadata.serializer(), finalMetadata)),
+ )
+
+ val response = try {
+ rsocket.requestResponse(request)
+ .let {
+ it.metadata?.readBytes()?.let { bytes ->
+ protobuf.decodeFromByteArray(bytes)
+ }!! to protobuf.decodeFromByteArray(deserializationStrategy, it.data.readBytes())
+ }
+ } catch (e: Exception) {
+ e
+ }
+
+ if (interceptors.response.isNotEmpty()) {
+ val result = interceptors.response.fold(
+ InterceptorContext(
+ data = when (response) {
+ is Exception -> Failure(response)
+ is Pair<*, *> -> Single(response.second as T)
+ else -> error("Should not reach here.")
+ },
+ metadata = (response as? Pair<*, *>)?.first as ServerMetadata,
+ options = options,
+ instances = requestContext?.instances ?: instances,
+ )
+ ) { acc, interceptor ->
+ interceptor.intercept(acc)
+ }
+
+ return if (result.data is Failure)
+ throw (result.data as Failure).exception
+ else result.data.requireSingle() as R
+ }
+
+ return when (response) {
+ is Exception -> throw response
+ is Pair<*, *> -> response.second as R
+ else -> error("Should not reach here.")
+ }
+ }
+
+ /**
+ * Makes a request-stream call.
+ *
+ * @param metadata Metadata to be sent with the request.
+ * @param data Data to be sent with the request.
+ * @param options Options for the request.
+ * @param serializationStrategy Serialization strategy for the data.
+ * @param deserializationStrategy Deserialization strategy for the response data.
+ * @return A flow of the response data.
+ */
+ @OptIn(InternalRRpcAPI::class)
+ public fun requestStream(
+ metadata: ClientMetadata,
+ data: T,
+ options: OptionsWithValue,
+ serializationStrategy: SerializationStrategy,
+ deserializationStrategy: DeserializationStrategy,
+ ): Flow = flow {
+ with(config) {
+ val requestContext = interceptors.runInputInterceptors(
+ Single(data),
+ metadata,
+ options,
+ instances,
+ )
+
+ val finalMetadata = requestContext?.metadata ?: metadata
+ val finalData = (requestContext?.data?.requireSingle() as? T) ?: data
+
+ val request = Payload(
+ ByteReadPacket(protobuf.encodeToByteArray(serializationStrategy, value = finalData)),
+ ByteReadPacket(protobuf.encodeToByteArray(ClientMetadata.serializer(), finalMetadata)),
+ )
+
+ handleStreamingResponse(
+ rsocket.requestStream(request),
+ options,
+ requestContext,
+ deserializationStrategy,
+ )
+ }
+ }
+
+ /**
+ * Makes a request-channel call.
+ *
+ * @param metadata Metadata to be sent with the request.
+ * @param data A flow of data to be sent with the request.
+ * @param options Options for the request.
+ * @param serializationStrategy Serialization strategy for the data.
+ * @param deserializationStrategy Deserialization strategy for the response data.
+ * @return A flow of the response data.
+ */
+ @OptIn(InternalRRpcAPI::class)
+ public fun requestChannel(
+ metadata: ClientMetadata,
+ data: Flow,
+ options: OptionsWithValue,
+ serializationStrategy: SerializationStrategy,
+ deserializationStrategy: DeserializationStrategy,
+ ): Flow = flow {
+ with(config) {
+ val requestContext = interceptors.runInputInterceptors(
+ Streaming(data),
+ metadata,
+ options,
+ instances,
+ )
+
+ handleStreamingResponse(
+ response = rsocket.requestChannel(
+ // in the initial payload, we put only metadata: it follows up the same logic
+ // how we treat responses (the first chunk contains only metadata) and
+ // makes it more idiomatic from the ProtoBuf RPC definition side.
+ initPayload = Payload(
+ data = ByteReadPacket.Empty,
+ metadata = ByteReadPacket(
+ protobuf.encodeToByteArray(
+ requestContext?.metadata ?: metadata
+ )
+ )
+ ),
+ payloads = (requestContext?.data?.requireStreaming() ?: data)
+ .map { Payload(ByteReadPacket(protobuf.encodeToByteArray(serializationStrategy, it as T))) },
+ ),
+ options = requestContext?.options ?: options,
+ requestContext = requestContext,
+ deserializationStrategy = deserializationStrategy,
+ )
+ }
+ }
+
+ public suspend fun fireAndForget(
+ metadata: ClientMetadata,
+ data: T,
+ options: OptionsWithValue,
+ serializationStrategy: SerializationStrategy,
+ ): Unit = with(config) {
+ TODO()
+ }
+
+ public suspend fun metadataPush(
+ metadata: ClientMetadata,
+ options: OptionsWithValue,
+ ): Unit = with(config) {
+ TODO()
+ }
+
+ /**
+ * Handles streaming responses, applying necessary response interceptors.
+ *
+ * @param response The flow of payloads from the server.
+ * @param options Options for the request.
+ * @param requestContext The context of the initial request.
+ * @param deserializationStrategy Deserialization strategy for the response data.
+ * @return A flow of the response data.
+ */
+ private suspend fun FlowCollector.handleStreamingResponse(
+ response: Flow,
+ options: OptionsWithValue,
+ requestContext: InterceptorContext?,
+ deserializationStrategy: DeserializationStrategy,
+ ): Unit = with(config) {
+ if (interceptors.response.isEmpty()) {
+ return emitAll(response.drop(1).map { protobuf.decodeFromByteArray(deserializationStrategy, it.data.readBytes()) })
+ }
+ // the first element is always metadata-only
+ val serverMetadata: ServerMetadata = protobuf.decodeFromByteArray(
+ response.first().metadata?.readBytes() ?: noMetadataError()
+ )
+
+ val context = interceptors.runOutputInterceptors(
+ Streaming(
+ response.map {
+ protobuf.decodeFromByteArray(deserializationStrategy, it.data.readBytes())
+ }
+ ),
+ serverMetadata,
+ options,
+ requestContext?.instances ?: instances,
+ )
+
+ emitAll(context!!.data.requireStreaming() as Flow)
+ }
+
+ /**
+ * Throws an error indicating that metadata is required but not present.
+ */
+ private fun noMetadataError(): Nothing =
+ error("No metadata was present in the response, but was a requirement. Please report.")
+}
diff --git a/client/core/src/commonMain/kotlin/org/timemates/rrpc/client/RRpcServiceClient.kt b/client/core/src/commonMain/kotlin/org/timemates/rrpc/client/RRpcServiceClient.kt
new file mode 100644
index 0000000..ec4b708
--- /dev/null
+++ b/client/core/src/commonMain/kotlin/org/timemates/rrpc/client/RRpcServiceClient.kt
@@ -0,0 +1,30 @@
+@file:OptIn(InternalRRpcAPI::class)
+
+package org.timemates.rrpc.client
+
+import org.timemates.rrpc.annotations.InternalRRpcAPI
+import org.timemates.rrpc.client.config.RRpcClientConfig
+import org.timemates.rrpc.client.options.RPCsOptions
+
+/**
+ * The abstraction for the clients that are generated by `RRpc`.
+ */
+public abstract class RRpcServiceClient(
+ config: RRpcClientConfig,
+) {
+ public constructor(
+ creator: RRpcClientConfig.Builder.() -> Unit,
+ ) : this(RRpcClientConfig.create(creator))
+
+ /**
+ * Responsible for the logic around RSocket for interceptors and request/response data management.
+ * No direct access to RSocket is used in the generated code.
+ */
+ protected val handler: ClientRequestHandler = ClientRequestHandler(config)
+
+ /**
+ * Container of the options related to the RPC methods by name.
+ * Used and generated internally by the code-generation.
+ */
+ protected abstract val rpcsOptions: RPCsOptions
+}
diff --git a/client/core/src/commonMain/kotlin/org/timemates/rrpc/client/config/RSPClientConfig.kt b/client/core/src/commonMain/kotlin/org/timemates/rrpc/client/config/RSPClientConfig.kt
new file mode 100644
index 0000000..535b66c
--- /dev/null
+++ b/client/core/src/commonMain/kotlin/org/timemates/rrpc/client/config/RSPClientConfig.kt
@@ -0,0 +1,162 @@
+@file:OptIn(ExperimentalInterceptorsApi::class)
+
+package org.timemates.rrpc.client.config
+
+import io.rsocket.kotlin.RSocket
+import kotlinx.serialization.ExperimentalSerializationApi
+import org.timemates.rrpc.annotations.ExperimentalInterceptorsApi
+import org.timemates.rrpc.annotations.InternalRRpcAPI
+import org.timemates.rrpc.instances.*
+import org.timemates.rrpc.interceptors.Interceptor
+import org.timemates.rrpc.interceptors.Interceptors
+import org.timemates.rrpc.metadata.ClientMetadata
+import org.timemates.rrpc.metadata.ServerMetadata
+import kotlin.properties.Delegates
+
+public data class RRpcClientConfig @OptIn(ExperimentalInterceptorsApi::class) constructor(
+ public val rsocket: RSocket,
+ public val interceptors: Interceptors,
+ public val instances: InstanceContainer,
+) {
+ public companion object {
+ public fun create(block: Builder.() -> Unit): RRpcClientConfig {
+ return Builder().apply {
+ @OptIn(ExperimentalSerializationApi::class)
+ // By default, Protobuf should always be present
+ instances { protobuf() }
+ block()
+ }.build()
+ }
+
+ public fun builder(): Builder = Builder().apply {
+ @OptIn(ExperimentalSerializationApi::class)
+ // By default, Protobuf should always be present
+ instances { protobuf() }
+ }
+ }
+
+ public class Builder {
+ /** List of interceptors for request processing */
+ @ExperimentalInterceptorsApi
+ private val requestInterceptors: MutableList> = mutableListOf()
+
+ /** List of interceptors for response processing */
+ @ExperimentalInterceptorsApi
+ private val responseInterceptors: MutableList> = mutableListOf()
+
+ /** The RSocket instance to use */
+ private var rsocket: RSocket by Delegates.notNull()
+
+ /** The container of provided instances */
+ private var instancesContainer: InstanceContainer? = null
+
+ /**
+ * Adds a list of interceptors for processing requests.
+ * @param interceptors List of request interceptors to add.
+ * @return This builder instance.
+ */
+ @ExperimentalInterceptorsApi
+ public fun requestInterceptors(interceptors: List>): Builder = apply {
+ this.requestInterceptors += interceptors
+ }
+
+ /**
+ * Adds one or more interceptors for processing requests.
+ * @param interceptors One or more request interceptors to add.
+ * @return This builder instance.
+ */
+ @ExperimentalInterceptorsApi
+ public fun requestInterceptors(vararg interceptors: Interceptor): Builder = apply {
+ this.requestInterceptors += interceptors
+ }
+
+ /**
+ * Adds an interceptor for processing requests.
+ * @param interceptor The request interceptor to add.
+ * @return This builder instance.
+ */
+ @ExperimentalInterceptorsApi
+ public fun requestInterceptor(interceptor: Interceptor): Builder = apply {
+ this.requestInterceptors += interceptor
+ }
+
+ /**
+ * Adds a list of interceptors for processing responses.
+ * @param interceptors List of response interceptors to add.
+ * @return This builder instance.
+ */
+ @ExperimentalInterceptorsApi
+ public fun responseInterceptors(interceptors: List>): Builder = apply {
+ this.responseInterceptors += interceptors
+ }
+
+ /**
+ * Adds one or more interceptors for processing responses.
+ * @param interceptors One or more response interceptors to add.
+ * @return This builder instance.
+ */
+ @ExperimentalInterceptorsApi
+ public fun responseInterceptors(vararg interceptors: Interceptor): Builder = apply {
+ this.responseInterceptors += interceptors
+ }
+
+ /**
+ * Adds an interceptor for processing responses.
+ * @param interceptor The response interceptor to add.
+ * @return This builder instance.
+ */
+ @ExperimentalInterceptorsApi
+ public fun responseInterceptor(interceptor: Interceptor): Builder = apply {
+ this.responseInterceptors += interceptor
+ }
+
+ /**
+ * Sets the RSocket instance to use.
+ * @param rsocket The RSocket instance.
+ * @return This builder instance.
+ */
+ public fun rsocket(rsocket: RSocket): Builder = apply {
+ this.rsocket = rsocket
+ }
+
+ /**
+ * Appends the provided in the [builder] instances to existing ones.
+ */
+ @OptIn(InternalRRpcAPI::class)
+ public fun instances(builder: InstancesBuilder.() -> Unit): Builder = apply {
+ val instances = InstancesBuilder().apply(builder).build()
+ instances(instances)
+ }
+
+ /**
+ * Appends the provided by [instances] parameter instances to existing ones.
+ */
+ public fun instances(instances: List): Builder = apply {
+ instancesContainer = if (instancesContainer == null)
+ InstanceContainer(instances.associateBy { it.key })
+ else instancesContainer!! + instances
+ }
+
+ /**
+ * Appends the provided [container] to the current one.
+ */
+ public fun instances(container: InstanceContainer): Builder = apply {
+ instancesContainer = if (instancesContainer == null)
+ container
+ else instancesContainer!! + container
+ }
+
+ /**
+ * Builds the final instance of [T] using the provided RSocket and interceptors.
+ * @return The built instance of [T].
+ */
+ @OptIn(ExperimentalInterceptorsApi::class)
+ public fun build(): RRpcClientConfig {
+ return RRpcClientConfig(
+ rsocket,
+ Interceptors(requestInterceptors, responseInterceptors),
+ instancesContainer ?: InstanceContainer(emptyMap()),
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/client/core/src/commonMain/kotlin/org/timemates/rrpc/client/options/RPCsOptions.kt b/client/core/src/commonMain/kotlin/org/timemates/rrpc/client/options/RPCsOptions.kt
new file mode 100644
index 0000000..4a2f3a1
--- /dev/null
+++ b/client/core/src/commonMain/kotlin/org/timemates/rrpc/client/options/RPCsOptions.kt
@@ -0,0 +1,18 @@
+package org.timemates.rrpc.client.options
+
+import org.timemates.rrpc.options.OptionsWithValue
+
+@JvmInline
+public value class RPCsOptions(
+ private val map: Map
+) {
+ public companion object {
+ public val EMPTY: RPCsOptions = RPCsOptions(emptyMap())
+ }
+
+ public constructor(vararg pairs: Pair) : this(mapOf(*pairs))
+
+ public operator fun get(name: String): OptionsWithValue? = map[name]
+}
+
+public fun RPCsOptions.getOrEmpty(name: String): OptionsWithValue = get(name) ?: OptionsWithValue.EMPTY
\ No newline at end of file
diff --git a/client/schema/README.md b/client/schema/README.md
new file mode 100644
index 0000000..72981a8
--- /dev/null
+++ b/client/schema/README.md
@@ -0,0 +1,2 @@
+# Server Schema Client
+This module is used to communicate with [server-schema](../../server/schema).
\ No newline at end of file
diff --git a/client/schema/build.gradle.kts b/client/schema/build.gradle.kts
new file mode 100644
index 0000000..6b65964
--- /dev/null
+++ b/client/schema/build.gradle.kts
@@ -0,0 +1,43 @@
+plugins {
+ id(libs.plugins.conventions.multiplatform.library.get().pluginId)
+ alias(libs.plugins.kotlinx.serialization)
+}
+
+group = "org.timemates.rrpc"
+version = System.getenv("LIB_VERSION") ?: "SNAPSHOT"
+
+dependencies {
+ // -- Project --
+ commonMainImplementation(projects.common.core)
+ commonMainImplementation(projects.common.schema)
+ commonMainImplementation(projects.client.core)
+
+ // -- Test --
+ jvmTestImplementation(libs.kotlin.test)
+ jvmTestImplementation(libs.mockk)
+}
+
+
+kotlin {
+ jvm()
+// js(IR) {
+// browser()
+// nodejs()
+// }
+// iosArm64()
+// iosX64()
+// iosSimulatorArm64()
+}
+
+mavenPublishing {
+ coordinates(
+ groupId = "org.timemates.rrpc",
+ artifactId = "server-metadata-client",
+ version = System.getenv("LIB_VERSION") ?: return@mavenPublishing,
+ )
+
+ pom {
+ name.set("RRpc Server Metadata Client")
+ description.set("Multiplatform Kotlin Library for working with Server Metadata.")
+ }
+}
diff --git a/client/schema/src/commonMain/kotlin/org/timemates/rrpc/client/schema/SchemaClient.kt b/client/schema/src/commonMain/kotlin/org/timemates/rrpc/client/schema/SchemaClient.kt
new file mode 100644
index 0000000..af6c937
--- /dev/null
+++ b/client/schema/src/commonMain/kotlin/org/timemates/rrpc/client/schema/SchemaClient.kt
@@ -0,0 +1,105 @@
+package org.timemates.rrpc.client.schema
+
+import org.timemates.rrpc.annotations.InternalRRpcAPI
+import org.timemates.rrpc.client.RRpcServiceClient
+import org.timemates.rrpc.client.config.RRpcClientConfig
+import org.timemates.rrpc.client.options.RPCsOptions
+import org.timemates.rrpc.client.schema.request.PagedRequest
+import org.timemates.rrpc.common.schema.RSFile
+import org.timemates.rrpc.common.schema.RSService
+import org.timemates.rrpc.metadata.ClientMetadata
+import org.timemates.rrpc.options.OptionsWithValue
+import org.timemates.rrpc.client.schema.request.BatchedRequest
+import org.timemates.rrpc.common.schema.RSType
+import io.rsocket.kotlin.RSocketError
+
+/**
+ * A client to interact with the `SchemaService` of the server, which provides metadata about
+ * available services, types, and extensions. This client sends requests to the server and
+ * receives the corresponding responses using the RSocket-based communication framework.
+ *
+ * @param config The configuration for the `RRpcServiceClient`, which defines connection settings.
+ */
+@OptIn(InternalRRpcAPI::class)
+public class SchemaClient(
+ config: RRpcClientConfig,
+) : RRpcServiceClient(config) {
+
+ public companion object {
+ private const val SERVICE_NAME: String = "timemates.rrpc.server.schema.SchemaService"
+ }
+
+ /**
+ * Creates an instance of the client using a configuration builder.
+ *
+ * @param creator A lambda to configure and build the `RRpcClientConfig` object.
+ */
+ public constructor(
+ creator: RRpcClientConfig.Builder.() -> Unit,
+ ) : this(RRpcClientConfig.create(creator))
+
+ override val rpcsOptions: RPCsOptions = RPCsOptions.EMPTY
+
+ /**
+ * Fetches a paged list of available services from the server.
+ *
+ * @param request A [PagedRequest] defining pagination settings.
+ * @return A [PagedRequest.Response] containing a list of [RSService] and the next page token.
+ *
+ * @throws RSocketError if the request fails.
+ */
+ public suspend fun getAvailableServices(request: PagedRequest): PagedRequest.Response {
+ return handler.requestResponse(
+ metadata = ClientMetadata(
+ serviceName = SERVICE_NAME,
+ procedureName = "GetAvailableServices",
+ ),
+ data = request,
+ options = OptionsWithValue.EMPTY,
+ serializationStrategy = PagedRequest.serializer(),
+ deserializationStrategy = PagedRequest.Response.serializer(RSService.serializer()),
+ )
+ }
+
+ /**
+ * Fetches a paged list of available files from the server.
+ *
+ * @param request A [PagedRequest] specifying pagination options.
+ * @return A [PagedRequest.Response] containing a list of [RSFile] and the next page token.
+ *
+ * @throws RSocketError if the request fails.
+ */
+ public suspend fun getAvailableFiles(request: PagedRequest): PagedRequest.Response {
+ return handler.requestResponse(
+ metadata = ClientMetadata(
+ serviceName = SERVICE_NAME,
+ procedureName = "GetAvailableFiles",
+ ),
+ data = request,
+ options = OptionsWithValue.EMPTY,
+ serializationStrategy = PagedRequest.serializer(),
+ deserializationStrategy = PagedRequest.Response.serializer(RSFile.serializer()),
+ )
+ }
+
+ /**
+ * Retrieves detailed information about multiple types in a batched request.
+ *
+ * @param request A [BatchedRequest] containing a list of [RMDeclarationUrl]s for the requested types.
+ * @return A [BatchedRequest.Response] containing a map of each requested [RMDeclarationUrl] to its associated [RSType].
+ *
+ * @throws RSocketError if the request fails.
+ */
+ public suspend fun getTypeDetailsBatch(request: BatchedRequest): BatchedRequest.Response {
+ return handler.requestResponse(
+ metadata = ClientMetadata(
+ serviceName = SERVICE_NAME,
+ procedureName = "GetTypeDetailsBatch",
+ ),
+ data = request,
+ options = OptionsWithValue.EMPTY,
+ serializationStrategy = BatchedRequest.serializer(),
+ deserializationStrategy = BatchedRequest.Response.serializer(RSType.serializer()),
+ )
+ }
+}
diff --git a/client/schema/src/commonMain/kotlin/org/timemates/rrpc/client/schema/request/BatchedRequest.kt b/client/schema/src/commonMain/kotlin/org/timemates/rrpc/client/schema/request/BatchedRequest.kt
new file mode 100644
index 0000000..9222a07
--- /dev/null
+++ b/client/schema/src/commonMain/kotlin/org/timemates/rrpc/client/schema/request/BatchedRequest.kt
@@ -0,0 +1,22 @@
+package org.timemates.rrpc.client.schema.request
+
+import kotlinx.serialization.Serializable
+import org.timemates.rrpc.common.schema.RSNode
+import org.timemates.rrpc.common.schema.value.RMDeclarationUrl
+
+/**
+ * Represents a batched request to retrieve multiple metadata entities by their declaration URLs.
+ *
+ * @property urls A list of RMDeclarationUrl objects, representing the metadata to retrieve.
+ */
+@Serializable
+public data class BatchedRequest(val urls: List) {
+ /**
+ * Response structure for batched requests.
+ * Contains a map of RMDeclarationUrl to the corresponding resolved metadata (or null if not found).
+ *
+ * @param results A map where each RMDeclarationUrl is associated with the corresponding RMNode (or null if not found).
+ */
+ @Serializable
+ public data class Response(public val services: Map)
+}
\ No newline at end of file
diff --git a/client/schema/src/commonMain/kotlin/org/timemates/rrpc/client/schema/request/PagedRequest.kt b/client/schema/src/commonMain/kotlin/org/timemates/rrpc/client/schema/request/PagedRequest.kt
new file mode 100644
index 0000000..8095aef
--- /dev/null
+++ b/client/schema/src/commonMain/kotlin/org/timemates/rrpc/client/schema/request/PagedRequest.kt
@@ -0,0 +1,40 @@
+package org.timemates.rrpc.client.schema.request
+
+import kotlinx.serialization.Serializable
+import kotlin.io.encoding.Base64
+import kotlin.io.encoding.ExperimentalEncodingApi
+
+/**
+ * Represents a paginated request to retrieve metadata entities.
+ *
+ * @property cursor A token used for retrieving the next page of results.
+ * @property size The maximum number of results to retrieve per page.
+ */
+@Serializable
+public data class PagedRequest(
+ public val cursor: String? = null,
+ public val size: Int? = null,
+) {
+ public companion object {
+ @OptIn(ExperimentalEncodingApi::class)
+ internal fun encoded(string: String): String {
+ return Base64.encode(string.toByteArray())
+ }
+ }
+
+ /**
+ * Response structure for paginated requests.
+ * Contains the list of metadata nodes retrieved and a token for the next page.
+ *
+ * @param list A list of RMNode objects representing the metadata retrieved.
+ * @param nextCursor A token to retrieve the next page of results, or null if no more results.
+ */
+ @Serializable
+ public data class Response(
+ public val list: List,
+ public val nextCursor: String?,
+ )
+}
+
+@OptIn(ExperimentalEncodingApi::class)
+internal fun PagedRequest.decoded(): String? = this@decoded.cursor?.let { String(Base64.decode(it)) }
\ No newline at end of file
diff --git a/code-generator/build.gradle.kts b/code-generator/build.gradle.kts
deleted file mode 100644
index cc2027f..0000000
--- a/code-generator/build.gradle.kts
+++ /dev/null
@@ -1,32 +0,0 @@
-plugins {
- id(libs.plugins.conventions.jvm.library.get().pluginId)
-}
-
-kotlin {
- explicitApi()
-}
-
-group = "org.timemates.rsproto"
-version = System.getenv("LIB_VERSION") ?: "SNAPSHOT"
-
-dependencies {
- implementation(libs.squareup.wire.schema)
- implementation(libs.squareup.kotlinpoet)
- implementation(libs.squareup.okio)
-
- testImplementation(libs.kotlin.test)
- testImplementation(libs.squareup.okio.fakeFs)
-}
-
-mavenPublishing {
- coordinates(
- groupId = "org.timemates.rsproto",
- artifactId = "code-generator",
- version = System.getenv("LIB_VERSION") ?: return@mavenPublishing,
- )
-
- pom {
- name.set("RSProto Code Generator")
- description.set("Code-generation library for RSProto servers and clients.")
- }
-}
\ No newline at end of file
diff --git a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/Annotations.kt b/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/Annotations.kt
deleted file mode 100644
index bffd49a..0000000
--- a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/Annotations.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package org.timemates.rsproto.codegen
-
-import com.squareup.kotlinpoet.AnnotationSpec
-import com.squareup.kotlinpoet.ClassName
-
-@Suppress("FunctionName")
-internal object Annotations {
- fun ProtoNumber(number: Int): AnnotationSpec =
- AnnotationSpec.builder(
- ClassName("kotlinx.serialization.protobuf", "ProtoNumber")
- ).addMember(number.toString()).build()
-
- val Serializable = AnnotationSpec.builder(ClassName("kotlinx.serialization", "Serializable")).build()
-
- fun OptIn(className: ClassName): AnnotationSpec = AnnotationSpec.builder(
- ClassName("kotlin", "OptIn")
- ).addMember("%T::class", className).build()
-
- fun Suppress(vararg warnings: String): AnnotationSpec = AnnotationSpec.builder(Suppress::class)
- .apply {
- warnings.forEach { warning ->
- addMember("\"$warning\"")
- }
- }
- .build()
-}
\ No newline at end of file
diff --git a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/CodeGenerator.kt b/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/CodeGenerator.kt
deleted file mode 100644
index 8877b97..0000000
--- a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/CodeGenerator.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package org.timemates.rsproto.codegen
-
-import com.squareup.wire.schema.Location
-import com.squareup.wire.schema.SchemaLoader
-import okio.FileSystem
-import okio.Path
-import kotlin.io.path.absolutePathString
-import kotlin.io.path.name
-
-public class CodeGenerator(
- private val fileSystem: FileSystem,
-) {
- public fun generate(
- rootPath: Path,
- outputPath: Path,
- clientGeneration: Boolean,
- serverGeneration: Boolean,
- ) {
- fileSystem.createDirectories(outputPath)
-
- val schemaLoader = SchemaLoader(fileSystem)
-
- schemaLoader.initRoots(listOf(Location.get(rootPath.toNioPath().absolutePathString())))
-
- val schema = schemaLoader.loadSchema()
-
- schema.protoFiles
- .filter {
- it.packageName?.startsWith("wire") != true &&
- it.location.toString() != "google/protobuf/descriptor.proto"
- }
- .map { file ->
- FileTransformer.transform(schema, file, clientGeneration, serverGeneration)
- }.forEach { file ->
- file.writeTo(outputPath.toNioPath())
- }
- }
-}
\ No newline at end of file
diff --git a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/Constant.kt b/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/Constant.kt
deleted file mode 100644
index 9388688..0000000
--- a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/Constant.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package org.timemates.rsproto.codegen
-
-internal object Constant {
- val GENERATED_COMMENT = """
- This file is generated by the `rsproto` library.
-
- WARNING: DO NOT MODIFY THIS FILE MANUALLY!
-
- Any changes made to this file will be overwritten when the code is regenerated.
- """.trimIndent()
-}
\ No newline at end of file
diff --git a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/FileTransformer.kt b/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/FileTransformer.kt
deleted file mode 100644
index 6f9abad..0000000
--- a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/FileTransformer.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-package org.timemates.rsproto.codegen
-
-import com.squareup.kotlinpoet.ClassName
-import com.squareup.kotlinpoet.FileSpec
-import com.squareup.wire.schema.ProtoFile
-import com.squareup.wire.schema.Schema
-import org.timemates.rsproto.codegen.services.client.ClientServiceApiGenerator
-import org.timemates.rsproto.codegen.services.server.ServerServiceTransformer
-import org.timemates.rsproto.codegen.types.TypeTransformer
-
-internal object FileTransformer {
- fun transform(schema: Schema, protoFile: ProtoFile, clientGeneration: Boolean, serverGeneration: Boolean): FileSpec {
- val fileName = ClassName(protoFile.javaPackage() ?: protoFile.packageName ?: "", protoFile.name())
-
- return FileSpec.builder(fileName).apply {
- addAnnotation(Annotations.Suppress("UNUSED", "RedundantVisibilityModifier"))
- addAnnotation(Annotations.OptIn(Types.experimentalSerializationApi))
- addFileComment(Constant.GENERATED_COMMENT)
-
- if(serverGeneration && protoFile.services.isNotEmpty()) {
- addImport(Types.serviceDescriptor.packageName, Types.serviceDescriptor.simpleName)
- addTypes(protoFile.services.map { ServerServiceTransformer.transform(it, schema) })
- }
-
- if(clientGeneration && protoFile.services.isNotEmpty()) {
- addTypes(protoFile.services.map { ClientServiceApiGenerator.generate(it, schema) })
- addImport(Types.payload.packageName, Types.payload.simpleName)
- addImport("kotlinx.serialization", listOf("encodeToByteArray", "decodeFromByteArray"))
- addImport("kotlinx.coroutines.flow", listOf("map"))
- addImport("io.ktor.utils.io.core", "readBytes")
- }
-
- val types = protoFile.types.map { TypeTransformer.transform(it, schema) }
-
- types.mapNotNull(TypeTransformer.Result::constructorFun)
- .forEach(::addFunction)
- addTypes(types.map { it.typeSpec })
- }.build()
- }
-}
\ No newline at end of file
diff --git a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/KotlinPoetExt.kt b/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/KotlinPoetExt.kt
deleted file mode 100644
index 0e42fa5..0000000
--- a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/KotlinPoetExt.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-package org.timemates.rsproto.codegen
-
-import com.squareup.kotlinpoet.CodeBlock
-import com.squareup.kotlinpoet.FileSpec
-import com.squareup.kotlinpoet.TypeSpec
-
-/**
- * Adds a list of TypeSpec to the FileSpec.
- *
- * @param types the list of TypeSpec to add.
- * @return the modified FileSpec.Builder.
- */
-internal fun FileSpec.Builder.addTypes(types: List): FileSpec.Builder = apply {
- types.forEach {
- addType(it)
- }
-}
-
-/**
- * Adds enum constants to the TypeSpec.Builder.
- *
- * @param names The names of the enum constants to be added.
- */
-internal fun TypeSpec.Builder.addEnumConstants(vararg names: String) {
- names.forEach {
- addEnumConstant(it)
- }
-}
-
-internal fun CodeBlock.Builder.addAllSeparated(codeBlocks: Iterable, separator: String = ",\n"): CodeBlock.Builder = apply {
- codeBlocks.forEach {
- add(it)
- add(",")
- add("\n")
- }
-}
\ No newline at end of file
diff --git a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/StringsExt.kt b/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/StringsExt.kt
deleted file mode 100644
index 06f9642..0000000
--- a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/StringsExt.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package org.timemates.rsproto.codegen
-
-internal fun String.capitalized(): String {
- return replaceFirstChar { it.uppercaseChar() }
-}
-
-internal fun String.decapitalize(): String {
- return replaceFirstChar { it.lowercaseChar() }
-}
\ No newline at end of file
diff --git a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/Types.kt b/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/Types.kt
deleted file mode 100644
index 1111702..0000000
--- a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/Types.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package org.timemates.rsproto.codegen
-
-import com.squareup.kotlinpoet.ClassName
-import com.squareup.kotlinpoet.ParameterizedTypeName
-import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
-import com.squareup.kotlinpoet.TypeName
-
-internal object Types {
- fun flow(ofTypeName: TypeName): ParameterizedTypeName =
- ClassName("kotlinx.coroutines.flow", "Flow")
- .parameterizedBy(ofTypeName)
-
- val serviceDescriptor = ClassName("org.timemates.rsproto.server.descriptors", "ServiceDescriptor")
-
- val byteReadPacket = ClassName("io.ktor.utils.io.core", "ByteReadPacket")
-
- val payload = ClassName("io.rsocket.kotlin.payload", "Payload")
-
- @Suppress("ClassName")
- object procedureDescriptor {
- val root = ClassName(
- "org.timemates.rsproto.server.descriptors", "ProcedureDescriptor"
- )
-
- val requestResponse = root.nestedClass("RequestResponse")
-
- val requestStream = root.nestedClass("RequestStream")
-
- val requestChannel = root.nestedClass("RequestChannel")
- }
-
- val metadata = ClassName("org.timemates.rsproto.metadata", "Metadata")
-
- val protoBuf = ClassName("kotlinx.serialization.protobuf", "ProtoBuf")
-
- val rsocket = ClassName("io.rsocket.kotlin", "RSocket")
-
- val rSocketService = ClassName("org.timemates.rsproto.server", "RSocketService")
-
- val experimentalSerializationApi = ClassName("kotlinx.serialization", "ExperimentalSerializationApi")
-
- val kserializer = ClassName("kotlinx.serialization", "KSerializer")
-}
\ No newline at end of file
diff --git a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/WireExt.kt b/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/WireExt.kt
deleted file mode 100644
index 5b7dcd7..0000000
--- a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/WireExt.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-package org.timemates.rsproto.codegen
-
-import com.squareup.kotlinpoet.ClassName
-import com.squareup.wire.schema.ProtoType
-import com.squareup.wire.schema.Rpc
-import com.squareup.wire.schema.Schema
-import com.squareup.wire.schema.internal.javaPackage
-
-internal fun ProtoType.asClassName(schema: Schema): ClassName {
- val file = schema.protoFile(this) ?: return ClassName(enclosingTypeOrPackage ?: "", simpleName)
-
- val packageName: String = (file.wirePackage() ?: file.javaPackage())?.plus(".") ?: ""
- val enclosingName: String = (enclosingTypeOrPackage?.replace(file.packageName ?: "", "") ?: "")
- .replace("..", ".")
-
- return ClassName(packageName + enclosingName, simpleName)
-}
-
-internal fun ProtoType.qualifiedName(schema: Schema): String {
- val packageName = schema.protoFile(this)?.packageName?.plus(".") ?: ""
-
- return packageName + simpleName
-}
-
-/**
- * Determines if the RPC is a request-response type.
- *
- * @return `true` if the RPC is a request-response type, `false` otherwise.
- */
-internal val Rpc.isRequestResponse get() = !requestStreaming && !responseStreaming
-
-/**
- * Determines if the Rpc is a request stream.
- *
- * @return true if the Rpc is a request stream, false otherwise.
- */
-internal val Rpc.isRequestStream get() = !requestStreaming && responseStreaming
-
-/**
- * Determines if the RPC is a request channel.
- *
- * @return `true` if the RPC is a request channel, `false` otherwise.
- */
-internal val Rpc.isRequestChannel get() = requestStreaming && responseStreaming
\ No newline at end of file
diff --git a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/services/RpcTransformer.kt b/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/services/RpcTransformer.kt
deleted file mode 100644
index 52dcd6c..0000000
--- a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/services/RpcTransformer.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package org.timemates.rsproto.codegen.services
-
-import com.squareup.kotlinpoet.FunSpec
-import com.squareup.kotlinpoet.KModifier
-import com.squareup.kotlinpoet.ParameterSpec
-import com.squareup.kotlinpoet.TypeName
-import com.squareup.wire.schema.Rpc
-import com.squareup.wire.schema.Schema
-import org.timemates.rsproto.codegen.Types
-import org.timemates.rsproto.codegen.asClassName
-import org.timemates.rsproto.codegen.decapitalize
-
-internal object RpcTransformer {
- fun transform(rpc: Rpc, schema: Schema): FunSpec {
- val (requestType, returnType) = getRpcType(rpc, schema)
-
- return FunSpec.builder(rpc.name.decapitalize())
- .addKdoc(rpc.documentation)
- .addModifiers(KModifier.ABSTRACT, KModifier.SUSPEND)
- .addParameter(
- ParameterSpec.builder(
- "request",
- requestType,
- ).build()
- )
- .returns(returnType)
- .build()
- }
-
- private fun getRpcType(rpc: Rpc, schema: Schema): Pair {
- val requestClassName = rpc.requestType!!.asClassName(schema)
- val responseClassName = rpc.responseType!!.asClassName(schema)
-
- return (if (rpc.requestStreaming) Types.flow(requestClassName) else requestClassName) to
- (if (rpc.responseStreaming) Types.flow(responseClassName) else responseClassName)
- }
-}
\ No newline at end of file
diff --git a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/services/client/ClientServiceApiGenerator.kt b/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/services/client/ClientServiceApiGenerator.kt
deleted file mode 100644
index 33e65e9..0000000
--- a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/services/client/ClientServiceApiGenerator.kt
+++ /dev/null
@@ -1,117 +0,0 @@
-package org.timemates.rsproto.codegen.services.client
-
-import com.squareup.kotlinpoet.*
-import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
-import com.squareup.wire.schema.Rpc
-import com.squareup.wire.schema.Schema
-import com.squareup.wire.schema.Service
-import org.timemates.rsproto.codegen.*
-
-internal object ClientServiceApiGenerator {
- fun generate(service: Service, schema: Schema): TypeSpec {
- return TypeSpec.classBuilder("${service.name}Api")
- .primaryConstructor(
- FunSpec.constructorBuilder()
- .addParameter("rsocket", Types.rsocket)
- .addParameter("protobuf", Types.protoBuf)
- .build()
- )
- .addProperty(
- PropertySpec.builder("rsocket", Types.rsocket)
- .addModifiers(KModifier.PRIVATE)
- .initializer("rsocket")
- .build()
- )
- .addProperty(
- PropertySpec.builder("protobuf", Types.protoBuf)
- .addModifiers(KModifier.PRIVATE)
- .initializer("protobuf").build()
- )
- .addFunctions(service.rpcs.map { mapRpc(it, service.type.qualifiedName(schema), schema) })
- .build()
- }
-
- private fun mapRpc(rpc: Rpc, serviceName: String, schema: Schema): FunSpec {
- if (rpc.requestStreaming && !rpc.responseStreaming)
- error("Client-only streaming is not supported.")
-
- val callCode = when {
- rpc.isRequestChannel ->
- "requestChannel(initialPayload, payloads)"
-
- rpc.isRequestResponse -> "requestResponse(payload)"
- rpc.isRequestStream -> "requestStream(payload)"
- else -> error("Should never reach this state")
- }
-
- val deserializationCode = when (rpc.responseStreaming) {
- true -> ".map·{ protobuf.decodeFromByteArray(it.data.readBytes()) }"
- false -> ".let·{ protobuf.decodeFromByteArray(it.data.readBytes()) }"
- }
-
- val code = when (rpc.requestStreaming) {
- true -> CodeBlock.builder()
- .addStatement("val encodedInitMessage = protobuf.encodeToByteArray(initMessage)")
- .addStatement(
- format = "val encodedMetadata = protobuf.encodeToByteArray(%1T(serviceName = %2S, procedureName = %3S, extra = extra))",
- args = arrayOf(Types.metadata, serviceName, rpc.name),
- )
- .addStatement(
- "val initPayload = Payload(data = %1T(encodedInitMessage), metadata = %1T(encodedMetadata))",
- Types.byteReadPacket
- )
- .addStatement("val payloads = messages.map { protobuf.encodeToByteArray(it) }")
- .addStatement("return rsocket.$callCode$deserializationCode")
- .build()
-
- false -> CodeBlock.builder()
- .addStatement("val encodedMessage = protobuf.encodeToByteArray(message)")
- .addStatement(
- format = "val encodedMetadata = protobuf.encodeToByteArray(%1T(%2S, %3S, extra))",
- args = arrayOf(Types.metadata, serviceName, rpc.name),
- )
- .addStatement(
- "val payload = Payload(data = %1T(encodedMessage), metadata = %1T(encodedMetadata))",
- Types.byteReadPacket
- )
- .addStatement("return rsocket.$callCode$deserializationCode")
- .build()
- }
-
- return FunSpec.builder(rpc.name.decapitalize())
- .apply {
- val className = rpc.requestType!!
- .asClassName(schema)
-
- if (rpc.requestStreaming) {
- addParameter(
- name = "initMessage",
- type = className,
- )
- addParameter(
- name = "messages",
- type = Types.flow(className)
- )
- } else {
- addParameter(
- name = "message",
- type = className,
- )
- }
- }
- .addParameter(
- ParameterSpec.builder("extra", MAP.parameterizedBy(STRING, BYTE_ARRAY))
- .defaultValue("emptyMap()")
- .build()
- )
- .apply {
- if (!rpc.requestStreaming && !rpc.responseStreaming) {
- addModifiers(KModifier.SUSPEND)
- }
- }
- .addCode(code)
- .returns(rpc.responseType!!.asClassName(schema)
- .let { if (rpc.responseStreaming) Types.flow(it) else it })
- .build()
- }
-}
\ No newline at end of file
diff --git a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/services/server/ServerServiceTransformer.kt b/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/services/server/ServerServiceTransformer.kt
deleted file mode 100644
index 10f7c1d..0000000
--- a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/services/server/ServerServiceTransformer.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-package org.timemates.rsproto.codegen.services.server
-
-import com.squareup.kotlinpoet.*
-import com.squareup.wire.schema.Rpc
-import com.squareup.wire.schema.Schema
-import com.squareup.wire.schema.Service
-import org.timemates.rsproto.codegen.*
-import org.timemates.rsproto.codegen.Types
-import org.timemates.rsproto.codegen.isRequestChannel
-import org.timemates.rsproto.codegen.isRequestResponse
-import org.timemates.rsproto.codegen.isRequestStream
-import org.timemates.rsproto.codegen.services.RpcTransformer
-
-internal object ServerServiceTransformer {
- fun transform(incoming: Service, schema: Schema): TypeSpec {
- val procedures = incoming.rpcs.map { RpcTransformer.transform(it, schema) }
-
- val procedureDescriptors = incoming.rpcs.map { rpc ->
- createDescriptor(rpc, rpc.requestType!!.asClassName(schema), rpc.responseType!!.asClassName(schema))
- }
-
- return TypeSpec.classBuilder(incoming.name)
- .superclass(Types.rSocketService)
- .addModifiers(KModifier.ABSTRACT)
- .addKdoc(incoming.documentation)
- .addProperty(
- PropertySpec.builder("descriptor", Types.serviceDescriptor)
- .addAnnotation(AnnotationSpec.builder(Suppress::class).addMember("\"UNCHECKED_CAST\"").build())
- .addModifiers(KModifier.FINAL, KModifier.OVERRIDE)
- .initializer(
- CodeBlock.builder()
- .addStatement("ServiceDescriptor(")
- .indent()
- .addStatement("name = %S,", incoming.type.qualifiedName(schema))
- .add("procedures = listOf(\n")
- .indent()
- .addAllSeparated(procedureDescriptors)
- .unindent()
- .addStatement(")")
- .unindent()
- .addStatement(")")
- .build()
- )
- .build()
- )
- .addFunctions(procedures)
- .build()
- }
-
- private fun createDescriptor(rpc: Rpc, receiverType: TypeName, returnType: TypeName): CodeBlock = CodeBlock.of(
- """
- %1T(
- name = "${rpc.name}",
- inputSerializer = %2T.serializer() as %4T,
- outputSerializer = %3T.serializer() as %4T,
- procedure = ${getProcedure(rpc)}
- )
- """.trimIndent(),
- args = arrayOf(
- when {
- rpc.isRequestResponse -> Types.procedureDescriptor.requestResponse
- rpc.isRequestStream -> Types.procedureDescriptor.requestStream
- rpc.isRequestChannel -> Types.procedureDescriptor.requestChannel
- else -> error("Should never reach this state.")
- },
- receiverType,
- returnType,
- Types.kserializer
- )
- )
-
- private fun getProcedure(rpc: Rpc) = when {
- rpc.isRequestResponse -> "{ ${rpc.name}(it as %2T) }"
- rpc.isRequestStream -> "{ ${rpc.name}(it as %2T) }"
- rpc.isRequestChannel -> "{ init, incoming -> ${rpc.name}(init as %2T, incoming·as·Flow<%2T>) }"
- else -> error("Request Streaming with no Response Streaming is not supported.")
- }
-}
\ No newline at end of file
diff --git a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/BuiltinsTransformer.kt b/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/BuiltinsTransformer.kt
deleted file mode 100644
index d231783..0000000
--- a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/BuiltinsTransformer.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package org.timemates.rsproto.codegen.types
-
-import com.squareup.kotlinpoet.*
-import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
-import com.squareup.wire.schema.ProtoType
-
-internal object BuiltinsTransformer {
- fun transform(incoming: ProtoType): TypeName {
- return when (incoming) {
- ProtoType.BOOL -> BOOLEAN
- ProtoType.INT32, ProtoType.SINT32, ProtoType.FIXED32, ProtoType.SFIXED32 -> INT
- ProtoType.INT64, ProtoType.SINT64, ProtoType.FIXED64, ProtoType.SFIXED64 -> LONG
- ProtoType.BYTES -> BYTE_ARRAY
- ProtoType.FLOAT -> FLOAT
- ProtoType.UINT32 -> U_INT
- ProtoType.UINT64 -> U_LONG
- ProtoType.DOUBLE -> DOUBLE
- ProtoType.DURATION -> TODO("This type is not yet implemented.")
- ProtoType.EMPTY -> UNIT
- ProtoType.STRING, ProtoType.TIMESTAMP -> STRING
- ProtoType.STRUCT_LIST ->
- LIST.parameterizedBy(ClassName(incoming.enclosingTypeOrPackage ?: "", incoming.simpleName))
- else -> ANY
- }
- }
-
-}
\ No newline at end of file
diff --git a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/EnclosingTypeTransformer.kt b/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/EnclosingTypeTransformer.kt
deleted file mode 100644
index 8fe73e3..0000000
--- a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/EnclosingTypeTransformer.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package org.timemates.rsproto.codegen.types
-
-import com.squareup.kotlinpoet.FunSpec
-import com.squareup.kotlinpoet.KModifier
-import com.squareup.kotlinpoet.TypeSpec
-import com.squareup.wire.schema.EnclosingType
-import com.squareup.wire.schema.Schema
-
-internal object EnclosingTypeTransformer {
-
- fun transform(incoming: EnclosingType, schema: Schema): TypeSpec {
- val nested = incoming.nestedTypes.map { TypeTransformer.transform(it, schema) }
-
- return TypeSpec.classBuilder(incoming.name)
- .primaryConstructor(
- FunSpec.constructorBuilder().addModifiers(KModifier.PRIVATE).build()
- )
- .addType(
- TypeSpec.companionObjectBuilder()
- .addFunctions(nested.mapNotNull(TypeTransformer.Result::constructorFun))
- .build()
- )
- .addTypes(nested.map(TypeTransformer.Result::typeSpec))
- .build()
- }
-
-}
\ No newline at end of file
diff --git a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/EnumTypeTransformer.kt b/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/EnumTypeTransformer.kt
deleted file mode 100644
index 3671f48..0000000
--- a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/EnumTypeTransformer.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package org.timemates.rsproto.codegen.types
-
-import com.squareup.kotlinpoet.TypeSpec
-import com.squareup.wire.schema.EnumType
-import com.squareup.wire.schema.Schema
-import org.timemates.rsproto.codegen.Annotations
-import org.timemates.rsproto.codegen.Types
-
-internal object EnumTypeTransformer {
- fun transform(incoming: EnumType, schema: Schema): TypeSpec {
- val nested = incoming.nestedTypes.map { TypeTransformer.transform(it, schema) }
-
- return TypeSpec.enumBuilder(incoming.name)
- .addAnnotation(Annotations.OptIn(Types.experimentalSerializationApi))
- .addAnnotation(Annotations.Serializable)
- .apply {
- incoming.constants.forEach { constant ->
- addEnumConstant(
- constant.name,
- TypeSpec.anonymousClassBuilder().addKdoc(constant.documentation)
- .addAnnotation(Annotations.ProtoNumber(constant.tag))
- .build()
- )
- }
- }
- .addTypes(nested.map(TypeTransformer.Result::typeSpec))
- .addType(
- TypeSpec.companionObjectBuilder()
- .addFunctions(nested.mapNotNull(TypeTransformer.Result::constructorFun))
- .build()
- )
- .build()
- }
-}
\ No newline at end of file
diff --git a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/MessageBuilderTransformer.kt b/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/MessageBuilderTransformer.kt
deleted file mode 100644
index 4443dc3..0000000
--- a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/MessageBuilderTransformer.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package org.timemates.rsproto.codegen.types
-
-import com.squareup.kotlinpoet.ClassName
-import com.squareup.kotlinpoet.FunSpec
-import com.squareup.kotlinpoet.PropertySpec
-import com.squareup.kotlinpoet.TypeSpec
-import com.squareup.wire.schema.Field
-import org.timemates.rsproto.codegen.Annotations
-
-internal object MessageBuilderTransformer {
- fun transform(
- name: String,
- declaredFields: List>,
- oneOfs: List,
- ): TypeSpec {
- val returnParametersSet = (declaredFields.map { it.first.name } + oneOfs.map { it.name })
- .joinToString(", ")
-
- return TypeSpec.classBuilder("Builder")
- .addProperties(declaredFields.map { (spec, type) ->
- spec.toBuilder().initializer(TypeDefaultValueTransformer.transform(type)).mutable(true).also {
- it.annotations.clear()
- }.build()
- })
- .addProperties(oneOfs.map { it.toBuilder().apply { annotations.clear()}.mutable(true).initializer("null").build() })
- .addFunction(
- FunSpec.builder("build")
- .addCode("return ${name}(${returnParametersSet})")
- .returns(ClassName("", name))
- .build()
- )
- .build()
- }
-
-}
\ No newline at end of file
diff --git a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/MessageTypeTransformer.kt b/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/MessageTypeTransformer.kt
deleted file mode 100644
index d70e3b6..0000000
--- a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/MessageTypeTransformer.kt
+++ /dev/null
@@ -1,120 +0,0 @@
-package org.timemates.rsproto.codegen.types
-
-import com.squareup.kotlinpoet.*
-import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
-import com.squareup.wire.schema.MessageType
-import com.squareup.wire.schema.Schema
-import org.timemates.rsproto.codegen.Annotations
-import org.timemates.rsproto.codegen.Types
-import org.timemates.rsproto.codegen.asClassName
-
-internal object MessageTypeTransformer {
- data class Result(val type: TypeSpec, val constructorFun: FunSpec?)
-
- fun transform(incoming: MessageType, schema: Schema): Result {
- val parameterTypes = incoming.declaredFields.map { field ->
- val fieldType = field.type!!
-
- when {
- fieldType.isScalar || fieldType.isWrapper || fieldType.isMap -> BuiltinsTransformer.transform(fieldType)
- .let {
- if (field.isRepeated)
- LIST.parameterizedBy(it)
- else it
- }
-
- else -> fieldType.asClassName(schema).let {
- when {
- field.isRepeated -> LIST.parameterizedBy(it)
- else -> it.copy(nullable = true)
- }
- }
- }
- }
-
- val oneOfs = incoming.oneOfs.map { OneOfGenerator.generate(it, schema) }
-
- val properties = (incoming.declaredFields).mapIndexed { index, field ->
- PropertySpec.builder(field.name, parameterTypes[index])
- .initializer(field.name)
- .addKdoc(field.documentation)
- .addAnnotation(Annotations.ProtoNumber(field.tag))
- .build()
- }
-
- val oneOfProperties = oneOfs.map { it.property }
-
- val className = incoming.type.asClassName(schema)
-
- val nested = incoming.nestedTypes.map { TypeTransformer.transform(it, schema) }
-
- return TypeSpec.classBuilder(className)
- .addAnnotation(Annotations.OptIn(Types.experimentalSerializationApi))
- .addKdoc(incoming.documentation)
- .addAnnotation(Annotations.Serializable)
- .primaryConstructor(
- FunSpec.constructorBuilder()
- .addModifiers(KModifier.PRIVATE)
- .addParameters(incoming.declaredFields.mapIndexed { index, field ->
- val type = parameterTypes[index]
-
- ParameterSpec.builder(field.name, type)
- .defaultValue(
- if (type.isNullable)
- "null"
- else field.default ?: TypeDefaultValueTransformer.transform(field)
- )
- .build()
- })
- .addParameters(oneOfs.map {
- ParameterSpec.builder(it.property.name, it.property.type).defaultValue("null").build()
- })
- .build()
- )
- .addType(
- TypeSpec.companionObjectBuilder()
- .addProperty(
- PropertySpec.builder("Default", className)
- .initializer("%T()", className)
- .build()
- )
- .addFunctions(nested.mapNotNull(TypeTransformer.Result::constructorFun))
- .build()
- )
- .addTypes(nested.map(TypeTransformer.Result::typeSpec))
- .apply {
- if(incoming.fields.isNotEmpty()) {
- addType(
- MessageBuilderTransformer.transform(
- incoming.name,
- properties.mapIndexed { index, it -> it to incoming.declaredFields[index] },
- oneOfs.map { it.property },
- )
- )
- }
- }
- .addProperties(properties)
- .addProperties(oneOfProperties)
- .addTypes(oneOfs.map(OneOfGenerator.Result::oneOfClass))
- .build()
- .let {
- Result(
- type = it,
- constructorFun = if (incoming.fields.isNotEmpty()) {
- val nestedClassName = incoming.type.asClassName(schema)
- FunSpec.builder(it.name!!)
- .addParameter(
- "builder",
- LambdaTypeName.get(
- receiver = nestedClassName.nestedClass("Builder"),
- returnType = UNIT,
- )
- )
- .addCode("return ${it.name}.Builder().apply(builder).build()")
- .returns(nestedClassName)
- .build()
- } else null,
- )
- }
- }
-}
\ No newline at end of file
diff --git a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/OneOfGenerator.kt b/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/OneOfGenerator.kt
deleted file mode 100644
index f1b6d5a..0000000
--- a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/OneOfGenerator.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-package org.timemates.rsproto.codegen.types
-
-import com.squareup.kotlinpoet.*
-import com.squareup.wire.schema.MessageType
-import com.squareup.wire.schema.OneOf
-import com.squareup.wire.schema.Schema
-import org.timemates.rsproto.codegen.Annotations
-import org.timemates.rsproto.codegen.asClassName
-import org.timemates.rsproto.codegen.capitalized
-
-internal object OneOfGenerator {
- data class Result(
- val oneOfClass: TypeSpec,
- val property: PropertySpec,
- )
-
- fun generate(oneof: OneOf, schema: Schema): Result {
- val oneOfName = "${oneof.name.capitalized()}OneOf"
- val oneOfClassName = ClassName("", oneOfName)
-
- val oneOfClass = TypeSpec.interfaceBuilder(oneOfName)
- .addAnnotation(Annotations.Serializable)
- .addModifiers(KModifier.SEALED)
- .addTypes(oneof.fields.map { field ->
- val defaultValue = TypeDefaultValueTransformer.transform(field)
- val typeName = field.type!!.asClassName(schema)
- val builder = typeName.nestedClass("Builder")
-
- val fieldName = field.name.capitalized()
-
- TypeSpec.valueClassBuilder(fieldName)
- .addAnnotation(Annotations.Serializable)
- .addAnnotation(JvmInline::class)
- .primaryConstructor(
- FunSpec.constructorBuilder()
- .addParameter(
- ParameterSpec.builder("value", typeName)
- .defaultValue(
- defaultValue.takeUnless { it == "null" } ?: "%T.Default", typeName,
- )
- .build()
- )
- .build()
- ).apply {
- val type = schema.getType(field.type!!)
- if (type is MessageType && type.fields.isNotEmpty())
- addFunction(
- FunSpec.constructorBuilder()
- .addParameter(
- name = "builder",
- type = LambdaTypeName.get(builder, returnType = UNIT),
- )
- .callThisConstructor(CodeBlock.of("%T().also(builder).build()", builder))
- .build()
- )
- }
- .addProperty(
- PropertySpec.builder("value", typeName).initializer("value")
- .addAnnotation(Annotations.ProtoNumber(field.tag))
- .build()
- )
- .addType(
- TypeSpec.companionObjectBuilder()
- .addProperty(
- PropertySpec.builder("Default", ClassName("", fieldName))
- .initializer("$fieldName()")
- .build()
- )
- .build()
- )
- .addSuperinterface(oneOfClassName)
- .build()
- })
- .build()
-
- val property = PropertySpec.builder(oneof.name, oneOfClassName.copy(nullable = true))
- .addKdoc(oneof.documentation)
- .initializer(oneof.name)
- .build()
-
- return Result(oneOfClass, property)
- }
-}
\ No newline at end of file
diff --git a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/TypeDefaultValueTransformer.kt b/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/TypeDefaultValueTransformer.kt
deleted file mode 100644
index 8053958..0000000
--- a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/TypeDefaultValueTransformer.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package org.timemates.rsproto.codegen.types
-
-import com.squareup.wire.schema.Field
-import com.squareup.wire.schema.ProtoType
-
-internal object TypeDefaultValueTransformer {
- fun transform(field: Field): String {
- val type = field.type!!
-
- if(field.isRepeated)
- return "emptyList()"
-
- return when (type) {
- ProtoType.INT32,
- ProtoType.INT64,
- ProtoType.DURATION,
- ProtoType.FIXED32,
- ProtoType.FIXED64,
- ProtoType.SFIXED32,
- ProtoType.SFIXED64,
- ProtoType.SINT32,
- ProtoType.SINT64 -> "0"
-
- ProtoType.UINT32, ProtoType.UINT64 -> "0u"
- ProtoType.STRING -> "\"\""
- ProtoType.BOOL -> "false"
- ProtoType.BYTES -> "byteArrayOf()"
- ProtoType.DOUBLE -> "0.0"
- ProtoType.FLOAT -> "0.0f"
- ProtoType.STRUCT_LIST -> "emptyList()"
- ProtoType.STRUCT_MAP -> "emptyMap()"
- ProtoType.TIMESTAMP -> ""
- else -> "null"
- }
- }
-
-}
\ No newline at end of file
diff --git a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/TypeTransformer.kt b/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/TypeTransformer.kt
deleted file mode 100644
index 98c6539..0000000
--- a/code-generator/src/main/kotlin/org/timemates/rsproto/codegen/types/TypeTransformer.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package org.timemates.rsproto.codegen.types
-
-import com.squareup.kotlinpoet.FunSpec
-import com.squareup.kotlinpoet.TypeSpec
-import com.squareup.wire.schema.*
-
-internal object TypeTransformer {
- data class Result(val typeSpec: TypeSpec, val constructorFun: FunSpec?)
-
- fun transform(incoming: Type, schema: Schema): Result {
- return when (incoming) {
- is MessageType -> MessageTypeTransformer.transform(incoming, schema)
- .let { Result(it.type, it.constructorFun) }
- is EnumType -> Result(EnumTypeTransformer.transform(incoming, schema), null)
- is EnclosingType -> Result(EnclosingTypeTransformer.transform(incoming, schema), null)
- }
- }
-
-}
\ No newline at end of file
diff --git a/common-core/build.gradle.kts b/common-core/build.gradle.kts
deleted file mode 100644
index 9019955..0000000
--- a/common-core/build.gradle.kts
+++ /dev/null
@@ -1,21 +0,0 @@
-plugins {
- id(libs.plugins.conventions.multiplatform.library.get().pluginId)
- alias(libs.plugins.kotlinx.serialization)
-}
-
-dependencies {
- commonMainImplementation(libs.kotlinx.serialization.proto)
-}
-
-group = "org.timemates.rsproto"
-version = System.getenv("LIB_VERSION") ?: "SNAPSHOT"
-
-kotlin {
- js(IR) {
- browser()
- nodejs()
- }
- iosArm64()
- iosX64()
- iosSimulatorArm64()
-}
\ No newline at end of file
diff --git a/common-core/src/commonMain/kotlin/org/timemates/rsproto/metadata/Metadata.kt b/common-core/src/commonMain/kotlin/org/timemates/rsproto/metadata/Metadata.kt
deleted file mode 100644
index d28e95b..0000000
--- a/common-core/src/commonMain/kotlin/org/timemates/rsproto/metadata/Metadata.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-@file:OptIn(ExperimentalSerializationApi::class)
-
-package org.timemates.rsproto.metadata
-
-import kotlinx.serialization.ExperimentalSerializationApi
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.protobuf.ProtoNumber
-
-/**
- * Represents metadata information.
- *
- * @property serviceName The name of the service.
- * @property procedureName The name of the procedure.
- * @property extra Additional key-value pairs of metadata.
- */
-@Serializable
-public data class Metadata(
- @ProtoNumber(0)
- val serviceName: String = "",
- @ProtoNumber(1)
- val procedureName: String = "",
- @ProtoNumber(2)
- val extra: Map = emptyMap(),
-)
\ No newline at end of file
diff --git a/common/core/build.gradle.kts b/common/core/build.gradle.kts
new file mode 100644
index 0000000..b9cb3d1
--- /dev/null
+++ b/common/core/build.gradle.kts
@@ -0,0 +1,40 @@
+plugins {
+ id(libs.plugins.conventions.multiplatform.library.get().pluginId)
+ alias(libs.plugins.kotlinx.serialization)
+}
+
+dependencies {
+ // -- Serialization --
+ commonMainApi(libs.kotlinx.serialization.proto)
+
+ // -- Coroutines --
+ commonMainImplementation(libs.kotlinx.coroutines)
+
+ // -- Test --
+ jvmTestImplementation(libs.kotlin.test)
+ jvmTestImplementation(libs.mockk)
+}
+
+
+kotlin {
+// js(IR) {
+// browser()
+// nodejs()
+// }
+// iosArm64()
+// iosX64()
+// iosSimulatorArm64()
+}
+
+mavenPublishing {
+ coordinates(
+ groupId = "org.timemates.rrpc",
+ artifactId = "common-core",
+ version = System.getenv("LIB_VERSION") ?: return@mavenPublishing,
+ )
+
+ pom {
+ name.set("RRpc Common Core")
+ description.set("Multiplatform Kotlin core library for RRpc servers and clients.")
+ }
+}
\ No newline at end of file
diff --git a/common/core/src/appleMain/kotlin/com/google/protobuf/TimestampExt.kt b/common/core/src/appleMain/kotlin/com/google/protobuf/TimestampExt.kt
new file mode 100644
index 0000000..2933583
--- /dev/null
+++ b/common/core/src/appleMain/kotlin/com/google/protobuf/TimestampExt.kt
@@ -0,0 +1,9 @@
+package com.google.protobuf
+
+import platform.Foundation.NSDate
+import platform.Foundation.dateWithTimeIntervalSince1970
+
+public fun ProtoTimestamp.toNSDate(): NSDate {
+ val totalSeconds = this.seconds + this.nanos / 1_000_000_000.0
+ return NSDate.dateWithTimeIntervalSince1970(totalSeconds)
+}
\ No newline at end of file
diff --git a/common/core/src/commonMain/kotlin/com/google/protobuf/ProtoAny.kt b/common/core/src/commonMain/kotlin/com/google/protobuf/ProtoAny.kt
new file mode 100644
index 0000000..c9aa250
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/com/google/protobuf/ProtoAny.kt
@@ -0,0 +1,156 @@
+package com.google.protobuf
+
+import kotlinx.serialization.*
+import kotlinx.serialization.protobuf.ProtoBuf
+import kotlinx.serialization.protobuf.ProtoNumber
+import org.timemates.rrpc.ProtoType
+
+
+/**
+ * Represents a serialized protobuf message of any type.
+ *
+ * `ProtoAny` is a Kotlin equivalent of `google.protobuf.Any`, which can encapsulate any
+ * protobuf message and provide type information for safe unpacking.
+ *
+ * #### Example
+ * Here's a small example of how to use ProtoAny type:
+ * ```kotlin
+ * // packing
+ * val any: ProtoAny = ProtoAny.pack(ProtoTimestamp.ofSeconds(...))
+ * // unpacking
+ * if (any.typeOf(ProtoTimestamp))
+ * println(any.unpack().seconds)
+ * else println("Other type: $typeName")
+ * ```
+ *
+ * @property typeName The fully qualified type name of the serialized message.
+ * @property value The serialized protobuf message as a byte array.
+ */
+@Serializable
+public class ProtoAny private constructor(
+ @ProtoNumber(1)
+ public val typeName: String = "",
+ @ProtoNumber(2)
+ public val value: ByteArray = byteArrayOf(),
+) : ProtoType {
+ public companion object : ProtoType.Definition {
+
+ /**
+ * Packs a given `ProtoType` instance into a `ProtoAny`.
+ *
+ * This function serializes the provided message and wraps it in a `ProtoAny` container.
+ * It is useful when you want to send or store an arbitrary protobuf message using the `Any` type.
+ *
+ * @param T The type of the `ProtoType` to be packed.
+ * @param value The message to be packed.
+ * @param serializer The serializer for the type `T`.
+ * @param protoBuf The `ProtoBuf` instance used for serialization, defaults to `ProtoBuf`.
+ * @return A `ProtoAny` instance containing the serialized message.
+ */
+ @OptIn(ExperimentalSerializationApi::class)
+ public fun pack(
+ value: T,
+ serializer: SerializationStrategy,
+ protoBuf: ProtoBuf = ProtoBuf,
+ ): ProtoAny {
+ val bytes = protoBuf.encodeToByteArray(serializer, value)
+ return ProtoAny(value.definition.url, bytes)
+ }
+
+ /**
+ * Returns the type URL for the `ProtoAny` type.
+ */
+ override val url: String
+ get() = "type.googleapis.com/google.protobuf.Any"
+
+ /**
+ * The default value for `ProtoAny`, which effectively means that there's no packed value.
+ */
+ override val Default: ProtoAny = ProtoAny()
+ }
+
+ /**
+ * Provides the definition of the `ProtoAny` type.
+ */
+ override val definition: ProtoType.Definition<*>
+ get() = Companion
+
+ /**
+ * Checks whether the `ProtoAny` contains a message of the given type.
+ *
+ * @param definition The definition of the type to check against.
+ * @return `true` if the contained message is of the given type, `false` otherwise.
+ */
+ public fun typeOf(definition: ProtoType.Definition<*>): Boolean {
+ return typeName == definition.url
+ }
+
+ /**
+ * Unpacks the `ProtoAny` instance into the specified `ProtoType`.
+ *
+ * This function deserializes the `ProtoAny`'s `value` field back into its original message type.
+ * It is useful when you want to retrieve the original message that was packed into an `Any` container.
+ *
+ * @param T The type of the `ProtoType` to be unpacked.
+ * @param deserializer The deserializer for the type `T`.
+ * @param protoBuf The `ProtoBuf` instance used for deserialization, defaults to `ProtoBuf`.
+ * @return The deserialized `ProtoType` instance.
+ * @throws SerializationException If the deserialization fails.
+ */
+ public fun unpack(
+ deserializer: DeserializationStrategy,
+ protoBuf: ProtoBuf = ProtoBuf,
+ ): T {
+ return protoBuf.decodeFromByteArray(deserializer, value)
+ }
+
+ override fun toString(): String {
+ return "ProtoAny(typeName='$typeName', value=$value)"
+ }
+}
+
+public val ProtoAny.isEmpty: Boolean get() = typeName.isEmpty() && value.isEmpty()
+
+/**
+ * Packs a given `ProtoType` instance into a `ProtoAny`.
+ *
+ * This function serializes the provided message and wraps it in a `ProtoAny` container.
+ * It is useful when you want to send or store an arbitrary protobuf message using the `Any` type.
+ *
+ * @param protoBuf The `ProtoBuf` instance used for serialization, defaults to `ProtoBuf`.
+ * @return A `ProtoAny` instance containing the serialized message.
+ */
+@OptIn(ExperimentalSerializationApi::class)
+public inline fun ProtoAny.Companion.pack(value: T, protoBuf: ProtoBuf = ProtoBuf): ProtoAny {
+ return pack(value, serializer(), protoBuf)
+}
+
+/**
+ * Unpacks a `ProtoAny` instance into the specified `ProtoType`.
+ *
+ * This function deserializes the `ProtoAny`'s `value` field back into its original message type.
+ * It is useful when you want to retrieve the original message that was packed into an `Any` container.
+ *
+ * @param protoBuf The `ProtoBuf` instance used for deserialization, defaults to `ProtoBuf`.
+ * @return The deserialized `ProtoType` instance.
+ * @throws SerializationException If the deserialization fails.
+ */
+@OptIn(ExperimentalSerializationApi::class)
+public inline fun ProtoAny.unpack(protoBuf: ProtoBuf = ProtoBuf): T {
+ return this.unpack(serializer(), protoBuf)
+}
+
+/**
+ * Tries to unpack the value and if it's failed, [defaultValue] is called
+ * as a fallback.
+ */
+public inline fun ProtoAny.unpackOr(
+ protoBuf: ProtoBuf = ProtoBuf,
+ defaultValue: () -> T,
+): T {
+ return try {
+ unpack(protoBuf)
+ } catch (_: Exception) {
+ defaultValue()
+ }
+}
\ No newline at end of file
diff --git a/common/core/src/commonMain/kotlin/com/google/protobuf/ProtoDuration.kt b/common/core/src/commonMain/kotlin/com/google/protobuf/ProtoDuration.kt
new file mode 100644
index 0000000..ca0d9c8
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/com/google/protobuf/ProtoDuration.kt
@@ -0,0 +1,73 @@
+@file:OptIn(ExperimentalSerializationApi::class)
+
+package com.google.protobuf
+
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.protobuf.ProtoNumber
+import org.timemates.rrpc.ProtoType
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.nanoseconds
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Constructs a [ProtoDuration] using a builder.
+ *
+ * @param builder A lambda function to configure the [ProtoDuration.Builder] instance.
+ * @return A [ProtoDuration] instance with the configured values.
+ */
+public fun ProtoDuration(builder: ProtoDuration.Builder.() -> Unit): ProtoDuration =
+ ProtoDuration.create(builder)
+
+/**
+ * Represents a duration as defined by the ProtoBuf specification.
+ * This corresponds to the `google.protobuf.Duration` type in Protocol Buffers.
+ *
+ * Refer to the [official documentation](https://protobuf.dev/reference/protobuf/google.protobuf/#duration)
+ * for more information.
+ */
+@Serializable
+public class ProtoDuration private constructor(
+ @ProtoNumber(1)
+ public val seconds: Long = 0,
+ @ProtoNumber(2)
+ public val nanos: Int = 0,
+) : ProtoType {
+ public companion object : ProtoType.Definition {
+ public fun ofSeconds(seconds: Long): ProtoDuration {
+ return ProtoDuration(seconds, 0)
+ }
+
+ /**
+ * Creates a [ProtoDuration] instance using a builder.
+ */
+ public fun create(builder: Builder.() -> Unit): ProtoDuration {
+ return Builder().apply(builder).build()
+ }
+
+ override val url: String
+ get() = "type.googleapis.com/google.protobuf.Duration"
+
+ override val Default: ProtoDuration = ProtoDuration()
+ }
+
+ public class Builder {
+ public var seconds: Long = 0
+ public var nanos: Int = 0
+
+ public fun build(): ProtoDuration {
+ return ProtoDuration(seconds, nanos)
+ }
+ }
+
+ override val definition: ProtoType.Definition<*>
+ get() = Companion
+
+ override fun toString(): String {
+ return "ProtoDuration(seconds=$seconds, nanos=$nanos)"
+ }
+}
+
+public fun ProtoDuration.toKotlinDuration(): Duration {
+ return seconds.seconds + nanos.nanoseconds
+}
\ No newline at end of file
diff --git a/common/core/src/commonMain/kotlin/com/google/protobuf/ProtoEmpty.kt b/common/core/src/commonMain/kotlin/com/google/protobuf/ProtoEmpty.kt
new file mode 100644
index 0000000..deb48ac
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/com/google/protobuf/ProtoEmpty.kt
@@ -0,0 +1,20 @@
+package com.google.protobuf
+
+import kotlinx.serialization.Serializable
+import org.timemates.rrpc.ProtoType
+
+@Serializable
+public class ProtoEmpty private constructor(): ProtoType {
+ public companion object : ProtoType.Definition {
+ override val url: String
+ get() = "type.googleapis.com/google.protobuf.Empty"
+ override val Default: ProtoEmpty = ProtoEmpty()
+ }
+
+ override val definition: ProtoType.Definition<*>
+ get() = Companion
+
+ override fun toString(): String {
+ return "ProtoEmpty()"
+ }
+}
\ No newline at end of file
diff --git a/common/core/src/commonMain/kotlin/com/google/protobuf/ProtoStruct.kt b/common/core/src/commonMain/kotlin/com/google/protobuf/ProtoStruct.kt
new file mode 100644
index 0000000..e722c6a
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/com/google/protobuf/ProtoStruct.kt
@@ -0,0 +1,87 @@
+@file:OptIn(ExperimentalSerializationApi::class)
+
+package com.google.protobuf
+
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.protobuf.ProtoNumber
+import kotlinx.serialization.protobuf.ProtoOneOf
+import org.timemates.rrpc.ProtoType
+import kotlin.jvm.JvmInline
+
+/**
+ * Represents a structured data type in ProtoBuf, similar to a JSON object.
+ *
+ * A [ProtoStruct] instance is a collection of fields, each with a name and a corresponding value.
+ * The `fields` property is a map where each key is a field name (string) and the value is of type [ProtoStructValue].
+ * This structure allows for dynamic and flexible data representation, where the values can be of various types, including nested structures.
+ *
+ * @property fields A map of field names to their corresponding values. The map is empty by default.
+ */
+@Serializable
+public class ProtoStruct private constructor(
+ public val fields: Map = emptyMap(),
+) : ProtoType {
+ public companion object : ProtoType.Definition {
+ override val url: String
+ get() = "type.googleapis.com/google.protobuf.Struct"
+ override val Default: ProtoStruct = ProtoStruct()
+
+ public fun of(vararg fields: Pair): ProtoStruct {
+ return of(mapOf(*fields))
+ }
+
+ public fun of(fields: Map): ProtoStruct {
+ return ProtoStruct(fields.mapValues { (_, value) -> ProtoStructValue(value) })
+ }
+ }
+
+ override val definition: ProtoType.Definition<*>
+ get() = Companion
+
+ override fun toString(): String {
+ return "ProtoStruct(fields=$fields)"
+ }
+}
+
+@Serializable
+@JvmInline
+public value class ProtoStructValue(
+ @ProtoOneOf public val kind: ProtoStructValueKind,
+)
+
+@Serializable
+public sealed class ProtoStructValueKind {
+ @Serializable
+ public class NullValue private constructor(
+ @Suppress("unused")
+ @ProtoNumber(1)
+ private val value: _NullValue = _NullValue.NULL_VALUE,
+ ) : ProtoStructValueKind() {
+ public companion object {
+ public val Default: NullValue = NullValue()
+ }
+ }
+
+ @Serializable
+ public data class NumberValue(@ProtoNumber(2) val value: Double) : ProtoStructValueKind()
+
+ @Serializable
+ public data class StringValue(@ProtoNumber(3) val value: String) : ProtoStructValueKind()
+
+ @Serializable
+ public data class BooleanValue(@ProtoNumber(4) val value: Boolean) : ProtoStructValueKind()
+
+ @Serializable
+ public data class StructValue(@ProtoNumber(5) val value: ProtoStruct) : ProtoStructValueKind()
+
+ @Serializable
+ public data class ListValue(@ProtoNumber(6) val value: List) : ProtoStructValueKind()
+}
+
+@Suppress("ClassName")
+@Serializable
+private enum class _NullValue {
+ @ProtoNumber(0)
+ NULL_VALUE
+}
diff --git a/common/core/src/commonMain/kotlin/com/google/protobuf/ProtoTimestamp.kt b/common/core/src/commonMain/kotlin/com/google/protobuf/ProtoTimestamp.kt
new file mode 100644
index 0000000..63ee1cf
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/com/google/protobuf/ProtoTimestamp.kt
@@ -0,0 +1,94 @@
+@file:OptIn(ExperimentalSerializationApi::class)
+
+package com.google.protobuf
+
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.protobuf.ProtoNumber
+import org.timemates.rrpc.ProtoType
+
+/**
+ * Constructs a [ProtoTimestamp] using a builder.
+ *
+ * This function allows you to create a [ProtoTimestamp] instance by applying configuration
+ * to a [Builder] and then building it. This is a convenient way to initialize a [ProtoTimestamp]
+ * with specific values for `seconds` and `nanos`.
+ *
+ * @param builder A lambda function to configure the [Builder] instance.
+ * @return A [ProtoTimestamp] instance with the configured values.
+ */
+public fun ProtoTimestamp(builder: ProtoTimestamp.Builder.() -> Unit): ProtoTimestamp =
+ ProtoTimestamp.create(builder)
+
+/**
+ * Represents a timestamp as defined by the ProtoBuf specification.
+ * This corresponds to the [google.protobuf.Timestamp] type in Protocol Buffers.
+ *
+ * A [ProtoTimestamp] holds the number of seconds and nanoseconds since the Unix epoch.
+ *
+ * Refer to the [official documentation](https://protobuf.dev/reference/protobuf/google.protobuf/#timestamp)
+ * for more information.
+ *
+ * @property seconds The number of seconds since the Unix epoch (1970-01-01T00:00:00Z).
+ * @property nanos The number of nanoseconds within the second.
+ */
+@Serializable
+public class ProtoTimestamp private constructor(
+ @ProtoNumber(1)
+ public val seconds: Long = 0,
+ @ProtoNumber(2)
+ public val nanos: Int = 0,
+) : ProtoType {
+ public companion object : ProtoType.Definition {
+ /**
+ * Creates a [ProtoTimestamp] representing a specific number of seconds since the Unix epoch.
+ *
+ * This function initializes the `seconds` property with the given value and sets `nanos` to 0.
+ *
+ * @param seconds The number of seconds since the Unix epoch.
+ * @return A [ProtoTimestamp] instance with the specified number of seconds and zero nanoseconds.
+ */
+ public fun ofSeconds(seconds: Long): ProtoTimestamp {
+ return ProtoTimestamp(seconds, 0)
+ }
+
+ /**
+ * Creates a [ProtoTimestamp] instance using a builder.
+ *
+ * This function allows you to configure a [ProtoTimestamp] instance using a builder pattern.
+ * It applies the provided lambda function to a [Builder] and then builds the [ProtoTimestamp].
+ *
+ * @param builder A lambda function to configure the [Builder] instance.
+ * @return A [ProtoTimestamp] instance with the configured values.
+ */
+ public fun create(builder: Builder.() -> Unit): ProtoTimestamp {
+ return Builder().apply(builder).build()
+ }
+
+ override val url: String
+ get() = "type.googleapis.com/google.protobuf.Timestamp"
+
+ /**
+ * The default value for [ProtoTimestamp], representing the Unix epoch (January 1, 1970).
+ *
+ * This default value also signifies the starting point of Unix time.
+ */
+ override val Default: ProtoTimestamp = ProtoTimestamp()
+ }
+
+ public class Builder {
+ public var seconds: Long = 0
+ public var nanos: Int = 0
+
+ public fun build(): ProtoTimestamp {
+ return ProtoTimestamp(seconds, nanos)
+ }
+ }
+
+ override val definition: ProtoType.Definition<*>
+ get() = Companion
+
+ override fun toString(): String {
+ return "Timestamp(second=$seconds, nanos=$nanos)"
+ }
+}
diff --git a/common/core/src/commonMain/kotlin/com/google/protobuf/ProtoWrappers.kt b/common/core/src/commonMain/kotlin/com/google/protobuf/ProtoWrappers.kt
new file mode 100644
index 0000000..eefa903
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/com/google/protobuf/ProtoWrappers.kt
@@ -0,0 +1,176 @@
+package com.google.protobuf
+
+import kotlinx.serialization.Serializable
+import org.timemates.rrpc.ProtoType
+
+/**
+ * Wrapper for a 32-bit integer value based on the standard Protobuf `Int32Value` type.
+ * For more information, see [Google's Int32Value documentation](https://protobuf.dev/reference/protobuf/google.protobuf/#int32-value).
+ *
+ * @property value The encapsulated integer value, defaulting to 0.
+ */
+@Serializable
+public class ProtoInt32Wrapper(
+ public val value: Int = 0,
+) : ProtoType {
+ public companion object Definition : ProtoType.Definition {
+ override val url: String = "type.googleapis.com/google.protobuf.Int32Value"
+ override val Default: ProtoInt32Wrapper = ProtoInt32Wrapper()
+ }
+
+ override val definition: ProtoType.Definition<*>
+ get() = Definition
+}
+
+/**
+ * Wrapper for a 64-bit integer value based on the standard Protobuf `Int64Value` type.
+ * For more information, see [Google's Int64Value documentation](https://protobuf.dev/reference/protobuf/google.protobuf/#int64-value).
+ *
+ * @property value The encapsulated long integer value, defaulting to 0L.
+ */
+@Serializable
+public class ProtoInt64Wrapper(
+ public val value: Long = 0L,
+) : ProtoType {
+ public companion object Definition : ProtoType.Definition {
+ override val url: String = "type.googleapis.com/google.protobuf.Int64Value"
+ override val Default: ProtoInt64Wrapper = ProtoInt64Wrapper()
+ }
+
+ override val definition: ProtoType.Definition<*>
+ get() = Definition
+}
+
+/**
+ * Wrapper for a 32-bit floating-point value based on the standard Protobuf `FloatValue` type.
+ * For more information, see [Google's FloatValue documentation](https://developers.google.com/protocol-buffers/docs/reference/google.protobuf#floatvalue).
+ *
+ * @property value The encapsulated float value, defaulting to 0.0F.
+ */
+@Serializable
+public class ProtoFloatWrapper(
+ public val value: Float = 0.0F,
+) : ProtoType {
+ public companion object Definition : ProtoType.Definition {
+ override val url: String = "type.googleapis.com/google.protobuf.FloatValue"
+ override val Default: ProtoFloatWrapper = ProtoFloatWrapper()
+ }
+
+ override val definition: ProtoType.Definition<*>
+ get() = Definition
+}
+
+/**
+ * Wrapper for a 64-bit floating-point value based on the standard Protobuf `DoubleValue` type.
+ * For more information, see [Google's DoubleValue documentation](https://developers.google.com/protocol-buffers/docs/reference/google.protobuf#doublevalue).
+ *
+ * @property value The encapsulated double value, defaulting to 0.0.
+ */
+@Serializable
+public class ProtoDoubleWrapper(
+ public val value: Double = 0.0,
+) : ProtoType {
+ public companion object Definition : ProtoType.Definition {
+ override val url: String = "type.googleapis.com/google.protobuf.DoubleValue"
+ override val Default: ProtoDoubleWrapper = ProtoDoubleWrapper()
+ }
+
+ override val definition: ProtoType.Definition<*>
+ get() = Definition
+}
+
+/**
+ * Wrapper for a boolean value based on the standard Protobuf `BoolValue` type.
+ * For more information, see [Google's BoolValue documentation](https://developers.google.com/protocol-buffers/docs/reference/google.protobuf#boolvalue).
+ *
+ * @property value The encapsulated boolean value, defaulting to `false`.
+ */
+@Serializable
+public class ProtoBoolWrapper(
+ public val value: Boolean = false,
+) : ProtoType {
+ public companion object Definition : ProtoType.Definition {
+ override val url: String = "type.googleapis.com/google.protobuf.BoolValue"
+ override val Default: ProtoBoolWrapper = ProtoBoolWrapper()
+ }
+
+ override val definition: ProtoType.Definition<*>
+ get() = Definition
+}
+
+/**
+ * Wrapper for a string value based on the standard Protobuf `StringValue` type.
+ * For more information, see [Google's StringValue documentation](https://developers.google.com/protocol-buffers/docs/reference/google.protobuf#stringvalue).
+ *
+ * @property value The encapsulated string value, defaulting to an empty string.
+ */
+@Serializable
+public class ProtoStringWrapper(
+ public val value: String = "",
+) : ProtoType {
+
+ public companion object Definition : ProtoType.Definition {
+ override val url: String = "type.googleapis.com/google.protobuf.StringValue"
+ override val Default: ProtoStringWrapper = ProtoStringWrapper()
+ }
+
+ override val definition: ProtoType.Definition<*>
+ get() = Definition
+}
+
+/**
+ * Wrapper for a byte array value based on the standard Protobuf `BytesValue` type.
+ * For more information, see [Google's BytesValue documentation](https://developers.google.com/protocol-buffers/docs/reference/google.protobuf#bytesvalue).
+ *
+ * @property value The encapsulated byte array value, defaulting to an empty byte array.
+ */
+@Serializable
+public class ProtoBytesWrapper(
+ public val value: ByteArray = ByteArray(0),
+) : ProtoType {
+ public companion object Definition : ProtoType.Definition {
+ override val url: String = "type.googleapis.com/google.protobuf.BytesValue"
+ override val Default: ProtoBytesWrapper = ProtoBytesWrapper()
+ }
+
+ override val definition: ProtoType.Definition<*>
+ get() = Definition
+}
+
+/**
+ * Wrapper for a 32-bit unsigned integer value based on the standard Protobuf `UInt32Value` type.
+ * For more information, see [Google's UInt32Value documentation](https://protobuf.dev/reference/protobuf/google.protobuf/#uint32-value).
+ *
+ * @property value The encapsulated unsigned integer value, defaulting to 0.
+ */
+@Serializable
+public class ProtoUInt32Wrapper(
+ public val value: UInt = 0u,
+) : ProtoType {
+ public companion object Definition : ProtoType.Definition {
+ override val url: String = "type.googleapis.com/google.protobuf.UInt32Value"
+ override val Default: ProtoUInt32Wrapper = ProtoUInt32Wrapper()
+ }
+
+ override val definition: ProtoType.Definition<*>
+ get() = Definition
+}
+
+/**
+ * Wrapper for a 64-bit unsigned integer value based on the standard Protobuf `UInt64Value` type.
+ * For more information, see [Google's UInt64Value documentation](https://protobuf.dev/reference/protobuf/google.protobuf/#uint64-value).
+ *
+ * @property value The encapsulated unsigned long integer value, defaulting to 0u.
+ */
+@Serializable
+public class ProtoUInt64Wrapper(
+ public val value: ULong = 0uL,
+) : ProtoType {
+ public companion object Definition : ProtoType.Definition {
+ override val url: String = "type.googleapis.com/google.protobuf.UInt64Value"
+ override val Default: ProtoUInt64Wrapper = ProtoUInt64Wrapper()
+ }
+
+ override val definition: ProtoType.Definition<*>
+ get() = Definition
+}
diff --git a/common/core/src/commonMain/kotlin/org/timemates/rrpc/DataVariant.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/DataVariant.kt
new file mode 100644
index 0000000..269b90d
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/DataVariant.kt
@@ -0,0 +1,167 @@
+package org.timemates.rrpc
+
+import kotlinx.coroutines.flow.Flow
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.contract
+import kotlin.jvm.JvmSynthetic
+
+/**
+ * Represents a value that can be in one of three states: a single value, a streaming value, or a failure.
+ *
+ * @param T The type of the value.
+ */
+public sealed interface DataVariant {
+
+ /**
+ * Checks if the other [DataVariant] is of the same type as this one.
+ *
+ * @param other The other [DataVariant] to compare with.
+ * @return `true` if the other [DataVariant] is of the same type, `false` otherwise.
+ */
+ public fun isSameType(other: DataVariant<*>): Boolean
+}
+
+/**
+ * Represents a single value.
+ *
+ * @param T The type of the value.
+ * @property value The single value.
+ */
+public data class Single(public val value: T) : DataVariant {
+ public companion object {
+ /**
+ * Denotes that single has no value inside. Usually, it's applicable only
+ * to the Metadata Push requests.
+ */
+ public val EMPTY: Single = Single(Unit)
+ }
+
+ override fun isSameType(other: DataVariant<*>): Boolean {
+ return other is Single
+ }
+
+ override fun toString(): String {
+ return "Single(value=$value)"
+ }
+}
+
+/**
+ * Represents a failure state, usually relevant only for clients as the server can return an error without a body.
+ * In addition, it's applicable only if original expected type is [Single]. For [Streaming] use flow's
+ * operators to handle failures.
+ *
+ * @param T The type of the value that was expected.
+ * @property exception The exception representing the failure.
+ */
+public data class Failure(public val exception: Exception) : DataVariant {
+ override fun isSameType(other: DataVariant<*>): Boolean {
+ return other is Failure
+ }
+}
+
+/**
+ * Represents a streaming value.
+ *
+ * @param T The type of the values in the stream.
+ * @property flow The flow of values.
+ */
+public class Streaming(
+ @get:JvmSynthetic
+ public val flow: Flow,
+) : DataVariant {
+ override fun isSameType(other: DataVariant<*>): Boolean {
+ return other is Streaming
+ }
+
+ override fun toString(): String {
+ return "Streaming(flow=$flow)"
+ }
+}
+
+/**
+ * Checks if the [DataVariant] is a single value.
+ *
+ * @return `true` if the variant is a [Single], `false` otherwise.
+ */
+@OptIn(ExperimentalContracts::class)
+public inline fun DataVariant.isSingle(): Boolean {
+ contract {
+ returns(true) implies (this@isSingle is Single)
+ returns(false) implies (this@isSingle !is Single)
+ }
+
+ return this is Single
+}
+
+/**
+ * Checks if the [DataVariant] is a streaming value.
+ *
+ * @return `true` if the variant is a [Streaming], `false` otherwise.
+ */
+@OptIn(ExperimentalContracts::class)
+public inline fun DataVariant.isStreaming(): Boolean {
+ contract {
+ returns(true) implies (this@isStreaming is Streaming)
+ returns(false) implies (this@isStreaming !is Streaming)
+ }
+
+ return this is Streaming
+}
+
+/**
+ * Checks if the [DataVariant] represents a failure.
+ *
+ * @return `true` if the variant is a [Failure], `false` otherwise.
+ */
+@OptIn(ExperimentalContracts::class)
+public inline fun DataVariant.isFailure(): Boolean {
+ contract {
+ returns(true) implies (this@isFailure is Failure)
+ returns(false) implies (this@isFailure !is Failure)
+ }
+
+ return this is Failure
+}
+
+/**
+ * Requires that the [DataVariant] is a single value and returns it.
+ *
+ * @return The single value.
+ * @throws IllegalStateException if the variant is not a [Single].
+ */
+@OptIn(ExperimentalContracts::class)
+public inline fun DataVariant.requireSingle(): T {
+ contract {
+ returns() implies (this@requireSingle is Single)
+ }
+ return if (isSingle()) this.value else error("Expected a single value, but got: $this.")
+}
+
+/**
+ * Requires that the [DataVariant] is a streaming value and returns it.
+ *
+ * @return The streaming value.
+ * @throws IllegalStateException if the variant is not a [Streaming].
+ */
+@OptIn(ExperimentalContracts::class)
+public inline fun DataVariant.requireStreaming(): Flow {
+ contract {
+ returns() implies (this@requireStreaming is Streaming)
+ }
+ return if (isStreaming()) this.flow else error("Expected a streaming value, but was not.")
+}
+
+/**
+ * Requires that the [DataVariant] is a failure and returns it.
+ *
+ * @return [Failure]
+ * @throws IllegalStateException if the variant is not a [Single].
+ */
+@OptIn(ExperimentalContracts::class)
+public inline fun DataVariant.requireFailure(): Exception {
+ contract {
+ returns() implies (this@requireFailure is Failure)
+ }
+ return if (isFailure()) this.exception else error("Expected a single value, but got: $this.")
+}
+
diff --git a/common/core/src/commonMain/kotlin/org/timemates/rrpc/ProtoType.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/ProtoType.kt
new file mode 100644
index 0000000..7e897e3
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/ProtoType.kt
@@ -0,0 +1,51 @@
+package org.timemates.rrpc
+
+/**
+ * Interface representing a type that can be serialized and deserialized using ProtoBuf.
+ *
+ * Implementing this interface allows a class to integrate with the ProtoBuf serialization framework.
+ * It provides access to the type's definition, which includes metadata and default instances.
+ */
+public interface ProtoType {
+
+ /**
+ * Provides the definition of the ProtoBuf type.
+ *
+ * The definition includes metadata about the type, such as its unique identifier and default instance.
+ *
+ * @return The definition of the ProtoBuf type.
+ */
+ public val definition: Definition<*>
+
+ /**
+ * Interface representing the definition of a ProtoBuf type.
+ *
+ * This interface contains metadata and a default instance for the ProtoBuf type. It helps in identifying
+ * the type and provides a way to access a default instance, which is useful for serialization and deserialization.
+ *
+ * @param T The type of the ProtoBuf type being defined. Must extend [ProtoType].
+ */
+ public interface Definition {
+
+ /**
+ * The unique identifier for the ProtoBuf type.
+ *
+ * This URL is used to identify the type within the ProtoBuf framework, particularly when working
+ * with `Any` types that encapsulate different messages.
+ *
+ * @return The type URL as a string.
+ */
+ public val url: String
+
+ /**
+ * The default instance of the ProtoBuf type.
+ *
+ * This instance represents the default or zero-value state of the type. It is often used as a starting point
+ * or as a placeholder when no specific value is provided.
+ *
+ * @return A default instance of the ProtoBuf type.
+ */
+ @Suppress("PropertyName")
+ public val Default: T
+ }
+}
diff --git a/common/core/src/commonMain/kotlin/org/timemates/rrpc/annotations/ExperimentalInterceptorsApi.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/annotations/ExperimentalInterceptorsApi.kt
new file mode 100644
index 0000000..bf1166e
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/annotations/ExperimentalInterceptorsApi.kt
@@ -0,0 +1,14 @@
+package org.timemates.rrpc.annotations
+
+@RequiresOptIn(message = "This API has subject to change.", level = RequiresOptIn.Level.WARNING)
+@Target(
+ allowedTargets = [
+ AnnotationTarget.CLASS,
+ AnnotationTarget.CONSTRUCTOR,
+ AnnotationTarget.FUNCTION,
+ AnnotationTarget.TYPEALIAS,
+ AnnotationTarget.VALUE_PARAMETER,
+ AnnotationTarget.PROPERTY
+ ],
+)
+public annotation class ExperimentalInterceptorsApi
\ No newline at end of file
diff --git a/common/core/src/commonMain/kotlin/org/timemates/rrpc/annotations/ExperimentalRSProtoAPI.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/annotations/ExperimentalRSProtoAPI.kt
new file mode 100644
index 0000000..b078ba0
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/annotations/ExperimentalRSProtoAPI.kt
@@ -0,0 +1,4 @@
+package org.timemates.rrpc.annotations
+
+@RequiresOptIn(message = "This API is considered as experimental.", level = RequiresOptIn.Level.WARNING)
+public annotation class ExperimentalRRpcAPI
\ No newline at end of file
diff --git a/common/core/src/commonMain/kotlin/org/timemates/rrpc/annotations/InternalRSProtoAPI.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/annotations/InternalRSProtoAPI.kt
new file mode 100644
index 0000000..643aaf4
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/annotations/InternalRSProtoAPI.kt
@@ -0,0 +1,4 @@
+package org.timemates.rrpc.annotations
+
+@RequiresOptIn(message = "This API is considered as internal.", level = RequiresOptIn.Level.ERROR)
+public annotation class InternalRRpcAPI
\ No newline at end of file
diff --git a/common/core/src/commonMain/kotlin/org/timemates/rrpc/exceptions/ProcedureNotFoundException.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/exceptions/ProcedureNotFoundException.kt
new file mode 100644
index 0000000..d5a831d
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/exceptions/ProcedureNotFoundException.kt
@@ -0,0 +1,7 @@
+package org.timemates.rrpc.exceptions
+
+import org.timemates.rrpc.metadata.ClientMetadata
+
+public class ProcedureNotFoundException(
+ metadata: ClientMetadata
+) : Exception("Procedure '${metadata.procedureName}' is not found in service named '${metadata.serviceName}'")
\ No newline at end of file
diff --git a/common/core/src/commonMain/kotlin/org/timemates/rrpc/exceptions/RSPException.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/exceptions/RSPException.kt
new file mode 100644
index 0000000..0b9186c
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/exceptions/RSPException.kt
@@ -0,0 +1,3 @@
+package org.timemates.rrpc.exceptions
+
+public abstract class RRpcException(message: String) : RuntimeException(message)
\ No newline at end of file
diff --git a/common/core/src/commonMain/kotlin/org/timemates/rrpc/exceptions/ServiceNotFoundException.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/exceptions/ServiceNotFoundException.kt
new file mode 100644
index 0000000..6ccd75e
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/exceptions/ServiceNotFoundException.kt
@@ -0,0 +1,5 @@
+package org.timemates.rrpc.exceptions
+
+public class ServiceNotFoundException(
+ serviceName: String,
+) : RRpcException("Service '$serviceName' is not found.")
\ No newline at end of file
diff --git a/common/core/src/commonMain/kotlin/org/timemates/rrpc/instances/InstanceContainer.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/instances/InstanceContainer.kt
new file mode 100644
index 0000000..edf86d4
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/instances/InstanceContainer.kt
@@ -0,0 +1,100 @@
+package org.timemates.rrpc.instances
+
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.protobuf.ProtoBuf
+import org.timemates.rrpc.annotations.InternalRRpcAPI
+import kotlin.coroutines.CoroutineContext
+
+/**
+ * Creates an [InstanceContainer] with the provided map of instances.
+ *
+ * @param instances A map where the key is a [ProvidableInstance.Key] and the value is a [ProvidableInstance].
+ * @return A new [InstanceContainer] containing the provided instances.
+ */
+public fun InstanceContainer(instances: Map, ProvidableInstance>): InstanceContainer =
+ InstanceContainerImpl(instances)
+
+/**
+ * Container for holding and retrieving instances of [ProvidableInstance].
+ *
+ * This interface allows retrieving instances using a key and adding new instances to the container.
+ */
+public interface InstanceContainer {
+ /**
+ * Retrieves an instance of type [T] associated with the given [key].
+ *
+ * @param key The key associated with the instance to be retrieved.
+ * @return The instance associated with the provided key.
+ */
+ public fun getInstance(key: ProvidableInstance.Key): T?
+
+ /**
+ * Creates a new [InstanceContainer] that contains all instances from this container plus the provided [instance].
+ *
+ * @param instance The instance to be added to the container.
+ * @return A new [InstanceContainer] containing the existing instances plus the provided instance.
+ */
+ public operator fun plus(instance: ProvidableInstance): InstanceContainer
+
+ /**
+ * Creates a new [InstanceContainer] that contains all instances from this container plus the provided [instances].
+ *
+ * @param instances The list of instances to be added to the container.
+ * @return A new [InstanceContainer] containing the existing instances plus the provided instances.
+ */
+ public operator fun plus(instances: List): InstanceContainer
+
+ /**
+ * Creates a new [InstanceContainer] that contains all instances from this container plus the provided [instances].
+ *
+ * @param container another container with instances to be appended.
+ * @return A new [InstanceContainer] containing the existing instances plus the provided instances.
+ */
+ public operator fun plus(container: InstanceContainer): InstanceContainer
+
+ /**
+ * Retrieves instances as map.
+ */
+ public fun asMap(): Map, ProvidableInstance>
+}
+
+internal class InstanceContainerImpl(
+ private val instances: Map, ProvidableInstance>
+) : InstanceContainer {
+ @Suppress("UNCHECKED_CAST")
+ override fun getInstance(key: ProvidableInstance.Key): T? =
+ instances[key] as? T
+
+
+ override operator fun plus(instance: ProvidableInstance): InstanceContainer {
+ return InstanceContainerImpl(instances + (instance.key to instance))
+ }
+
+
+ override operator fun plus(instances: List): InstanceContainer {
+ return InstanceContainerImpl(
+ this.instances.plus(instances.associateBy(ProvidableInstance::key))
+ )
+ }
+
+ override fun plus(container: InstanceContainer): InstanceContainer {
+ return InstanceContainer(instances + container.asMap())
+ }
+
+ override fun asMap(): Map, ProvidableInstance> {
+ return instances
+ }
+}
+
+@OptIn(ExperimentalSerializationApi::class)
+public val InstanceContainer.protobuf: ProtoBuf?
+ get() = getInstance(ProtobufInstance)?.protobuf
+
+@InternalRRpcAPI
+public class CoroutineContextInstanceContainer(
+ public val container: InstanceContainer,
+) : CoroutineContext.Element {
+ override val key: CoroutineContext.Key<*> = Key
+
+ public companion object Key : CoroutineContext.Key
+}
\ No newline at end of file
diff --git a/common/core/src/commonMain/kotlin/org/timemates/rrpc/instances/InstancesBuilder.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/instances/InstancesBuilder.kt
new file mode 100644
index 0000000..ca5f668
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/instances/InstancesBuilder.kt
@@ -0,0 +1,23 @@
+package org.timemates.rrpc.instances
+
+import org.timemates.rrpc.annotations.InternalRRpcAPI
+
+public class InstancesBuilder @InternalRRpcAPI constructor() {
+ private val instances: MutableSet = mutableSetOf()
+
+ public fun register(instance: ProvidableInstance) {
+ if (instances.contains(instance))
+ instances.remove(instance)
+ instances += instance
+ }
+
+ @InternalRRpcAPI
+ public fun build(): List {
+ return instances.toList()
+ }
+}
+
+public fun instances(block: InstancesBuilder.() -> Unit): List {
+ @OptIn(InternalRRpcAPI::class)
+ return InstancesBuilder().also(block).build()
+}
\ No newline at end of file
diff --git a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/instances/ProtobufInstance.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/instances/ProtobufInstance.kt
similarity index 54%
rename from server-core/src/commonMain/kotlin/org/timemates/rsproto/server/instances/ProtobufInstance.kt
rename to common/core/src/commonMain/kotlin/org/timemates/rrpc/instances/ProtobufInstance.kt
index b90f57c..aa69f13 100644
--- a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/instances/ProtobufInstance.kt
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/instances/ProtobufInstance.kt
@@ -1,15 +1,13 @@
-package org.timemates.rsproto.server.instances
+package org.timemates.rrpc.instances
-import org.timemates.rsproto.server.RSocketProtoServerBuilder
-import org.timemates.rsproto.server.annotations.ExperimentalInstancesApi
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf
import kotlinx.serialization.protobuf.ProtoBufBuilder
+import kotlin.jvm.JvmInline
-@ExperimentalInstancesApi
@OptIn(ExperimentalSerializationApi::class)
@JvmInline
-public value class ProtobufInstance(public val protoBuf: ProtoBuf) : ProvidableInstance {
+public value class ProtobufInstance(public val protobuf: ProtoBuf) : ProvidableInstance {
public companion object Key : ProvidableInstance.Key
@@ -18,27 +16,25 @@ public value class ProtobufInstance(public val protoBuf: ProtoBuf) : ProvidableI
}
/**
- * Register the protobuf instance with the RSocketProtoServerBuilder.
+ * Register the protobuf instance for the [InstanceContainer].
*
* @param protobuf The ProtoBuf instance to register.
*/
@OptIn(ExperimentalSerializationApi::class)
-@ExperimentalInstancesApi
-public fun RSocketProtoServerBuilder.InstancesBuilder.protobuf(
- protobuf: ProtoBuf
+public fun InstancesBuilder.protobuf(
+ protobuf: ProtoBuf = ProtoBuf
) {
register(ProtobufInstance(protobuf))
}
/**
- * Registers a Protobuf instance with the RSocketProtoServerBuilder.
- * This allows the server to handle remote method calls using Protobuf encoding.
+ * Registers a Protobuf instance for the [InstanceContainer].
+ * This allows the client/server to handle remote method calls using Protobuf encoding.
*
* @param builder A lambda function that configures the Protobuf instance using the [ProtoBufBuilder].
*/
@OptIn(ExperimentalSerializationApi::class)
-@ExperimentalInstancesApi
-public fun RSocketProtoServerBuilder.InstancesBuilder.protobuf(
+public fun InstancesBuilder.protobuf(
builder: ProtoBufBuilder.() -> Unit
) {
register(ProtobufInstance(ProtoBuf(builderAction = builder)))
diff --git a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/instances/ProvidableInstance.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/instances/ProvidableInstance.kt
similarity index 54%
rename from server-core/src/commonMain/kotlin/org/timemates/rsproto/server/instances/ProvidableInstance.kt
rename to common/core/src/commonMain/kotlin/org/timemates/rrpc/instances/ProvidableInstance.kt
index a9bfdd1..4dbe182 100644
--- a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/instances/ProvidableInstance.kt
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/instances/ProvidableInstance.kt
@@ -1,14 +1,10 @@
-package org.timemates.rsproto.server.instances
-
-import org.timemates.rsproto.server.annotations.ExperimentalInstancesApi
+package org.timemates.rrpc.instances
/**
* A contract for classes that can provide instances based on a specified key.
*/
-@ExperimentalInstancesApi
public interface ProvidableInstance {
public val key: Key<*>
- @ExperimentalInstancesApi
public interface Key
}
\ No newline at end of file
diff --git a/common/core/src/commonMain/kotlin/org/timemates/rrpc/interceptors/Interceptor.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/interceptors/Interceptor.kt
new file mode 100644
index 0000000..afbd885
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/interceptors/Interceptor.kt
@@ -0,0 +1,115 @@
+@file:OptIn(InternalRRpcAPI::class)
+
+package org.timemates.rrpc.interceptors
+
+import org.timemates.rrpc.DataVariant
+import org.timemates.rrpc.Failure
+import org.timemates.rrpc.annotations.ExperimentalInterceptorsApi
+import org.timemates.rrpc.annotations.InternalRRpcAPI
+import org.timemates.rrpc.instances.InstanceContainer
+import org.timemates.rrpc.metadata.ClientMetadata
+import org.timemates.rrpc.metadata.RRpcMetadata
+import org.timemates.rrpc.metadata.ServerMetadata
+import org.timemates.rrpc.options.OptionsWithValue
+
+/**
+ * Represents an interceptor that processes metadata of type [TMetadata].
+ *
+ * @param TMetadata The type of metadata that the interceptor processes.
+ */
+@ExperimentalInterceptorsApi
+public interface Interceptor {
+ /**
+ * Intercepts and processes the given [context].
+ *
+ * **ANY consumption of request input / output that is Flow should rely
+ * on the functions like [kotlinx.coroutines.flow.onEach]. Otherwise, it will lead to data loss or performance problems.**
+ *
+ * @param context The context containing metadata to be processed by the interceptor.
+ * @return The processed [InterceptorContext] with potentially modified metadata.
+ */
+ public suspend fun intercept(
+ context: InterceptorContext,
+ ): InterceptorContext
+}
+
+/**
+ * A type alias for an interceptor that processes client metadata.
+ */
+@ExperimentalInterceptorsApi
+public typealias RequestInterceptor = Interceptor
+
+/**
+ * A type alias for an interceptor that processes server metadata.
+ */
+@ExperimentalInterceptorsApi
+public typealias ResponseInterceptor = Interceptor
+
+@ExperimentalInterceptorsApi
+public data class Interceptors(
+ val request: List>,
+ val response: List>,
+) {
+ /**
+ * Runs input interceptors and returns the result of the provided block.
+ *
+ * @param data The input data.
+ * @param clientMetadata The client metadata.
+ * @param options The options for the procedure.
+ * @param instanceContainer The instance container.
+ * @param block The block of code to execute after running the interceptors.
+ * @return The result of the block execution.
+ */
+ @InternalRRpcAPI
+ public suspend inline fun runInputInterceptors(
+ data: DataVariant<*>,
+ clientMetadata: ClientMetadata,
+ options: OptionsWithValue,
+ instanceContainer: InstanceContainer,
+ ): InterceptorContext? {
+ if (request.isNotEmpty()) {
+ val initialContext = InterceptorContext(data, clientMetadata, options, instanceContainer)
+ return request.fold(initialContext) { acc, interceptor ->
+ try {
+ interceptor.intercept(acc)
+ } catch (e: Exception) {
+ interceptor.intercept(acc.copy(data = Failure(e)))
+ }
+ }
+ }
+
+ return null
+ }
+
+ /**
+ * Runs output interceptors on the provided data.
+ *
+ * **API Note**: The API is considered internal, to backward-compatibility is
+ * guaranteed.
+ *
+ * @param data The output data.
+ * @param serverMetadata The server metadata.
+ * @param options The options for the procedure.
+ * @param instanceContainer The instance container.
+ *
+ * @return [InterceptorContext] or null if no interceptors were involved.
+ */
+ @InternalRRpcAPI
+ public suspend fun runOutputInterceptors(
+ data: DataVariant<*>,
+ serverMetadata: ServerMetadata,
+ options: OptionsWithValue,
+ instanceContainer: InstanceContainer
+ ): InterceptorContext? {
+ return if (response.isNotEmpty()) {
+ val initialContext = InterceptorContext(data, serverMetadata, options, instanceContainer)
+ response.fold(initialContext) { acc, interceptor ->
+ try {
+ interceptor.intercept(acc)
+ } catch (e: Exception) {
+ interceptor.intercept(acc.copy(data = Failure(e)))
+ }
+ }
+ } else null
+ }
+}
\ No newline at end of file
diff --git a/common/core/src/commonMain/kotlin/org/timemates/rrpc/interceptors/InterceptorContext.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/interceptors/InterceptorContext.kt
new file mode 100644
index 0000000..c12e54e
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/interceptors/InterceptorContext.kt
@@ -0,0 +1,166 @@
+@file:Suppress("MemberVisibilityCanBePrivate")
+
+package org.timemates.rrpc.interceptors
+
+import org.timemates.rrpc.DataVariant
+import org.timemates.rrpc.annotations.InternalRRpcAPI
+import org.timemates.rrpc.instances.InstanceContainer
+import org.timemates.rrpc.instances.ProvidableInstance
+import org.timemates.rrpc.metadata.ExtraMetadata
+import org.timemates.rrpc.metadata.RRpcMetadata
+import org.timemates.rrpc.options.OptionsWithValue
+import kotlin.jvm.JvmSynthetic
+
+/**
+ * Represents the context for interceptors to access and modify request-related data,
+ * metadata, and options during request processing.
+ *
+ * @property data Request/response data linked to the request, which can be either
+ * single-value or streaming data.
+ * @property metadata Request metadata attached to the request.
+ * @property options Options linked to the request.
+ * @property instances Container for instances available within the request pipeline.
+ */
+@OptIn(InternalRRpcAPI::class)
+public class InterceptorContext @InternalRRpcAPI constructor(
+ public val data: DataVariant<*>,
+ public val metadata: TMetadata,
+ public val options: OptionsWithValue,
+ public val instances: InstanceContainer,
+) {
+
+ /**
+ * Provides a builder to modify the context immutably, preserving the current state of the context.
+ */
+ public fun builder(): Builder {
+ return Builder(
+ instances, metadata, data, options,
+ )
+ }
+
+
+ /**
+ * Copies current [InterceptorContext] with the ability to change some of its fields.
+ *
+ * It's better to use [builder] as it ensures that no data was lost during the chain of interceptors.
+ * Make sure that [instances] are properly handled and nothing lost.
+ *
+ * Marked as [JvmSynthetic], because named arguments are not supported from Java side.
+ */
+ @JvmSynthetic
+ public fun copy(
+ data: DataVariant<*> = this.data,
+ extra: ExtraMetadata = this.metadata.extra,
+ options: OptionsWithValue = this.options,
+ instances: InstanceContainer = this.instances,
+ ): InterceptorContext {
+ @Suppress("UNCHECKED_CAST")
+ return InterceptorContext(
+ data, metadata.extra(extra) as TMetadata, options, instances,
+ )
+ }
+
+ /**
+ * Builder class for modifying an immutable [InterceptorContext].
+ */
+ public class Builder(
+ private var instances: InstanceContainer,
+ private var metadata: TMetadata,
+ private var data: DataVariant<*>,
+ private var options: OptionsWithValue,
+ ) {
+ /**
+ * Adds local instances to the given request pipeline. These instances do not affect
+ * the global [InstanceContainer].
+ *
+ * @param instance The instance to add to the local request pipeline.
+ * @return This builder instance for chaining method calls.
+ */
+ public fun addLocalInstance(instance: ProvidableInstance): Builder {
+ instances += instance
+ return this
+ }
+
+ /**
+ * Adds multiple local instances to the given request pipeline. These instances do not affect
+ * the global [InstanceContainer].
+ *
+ * @param list The list of instances to add to the local request pipeline.
+ * @return This builder instance for chaining method calls.
+ */
+ public fun addLocalInstances(list: List): Builder {
+ instances += list
+ return this
+ }
+
+ /**
+ * Adds extra metadata to the builder, modifying the current metadata instance.
+ *
+ * @param extra The extra metadata to add.
+ * @return This builder instance for chaining method calls.
+ */
+ public fun extras(extra: ExtraMetadata): Builder {
+ @Suppress("UNCHECKED_CAST")
+ metadata = metadata.extra(extra) as TMetadata
+ return this
+ }
+
+ /**
+ * Sets the request/response data in the builder. Ensures that the incoming data type matches
+ * the current data type to prevent confusion and potential type errors.
+ *
+ * @param value The new value for request/response data.
+ * @return This builder instance for chaining method calls.
+ * @throws IllegalArgumentException if the data type of [value] does not match the current data type.
+ */
+ public fun data(value: DataVariant<*>): Builder {
+ data = value
+ return this
+ }
+
+ /**
+ * Sets the options in the builder.
+ *
+ * @param options The new options for the request.
+ */
+ public fun options(options: OptionsWithValue): Builder {
+ this.options = options
+ return this
+ }
+
+ /**
+ * Constructs an immutable [InterceptorContext] instance based on the current builder state.
+ *
+ * @return An immutable [InterceptorContext] instance.
+ */
+ public fun build(): InterceptorContext {
+ return InterceptorContext(
+ data, metadata, options, instances
+ )
+ }
+ }
+}
+
+/**
+ * Adds multiple local instances to the [InterceptorContext.Builder] using vararg parameter syntax.
+ *
+ * @param instances The vararg list of instances to add to the builder.
+ * @return The [InterceptorContext.Builder] instance with added local instances.
+ */
+public fun InterceptorContext.Builder.addLocalInstances(
+ vararg instances: ProvidableInstance
+): InterceptorContext.Builder {
+ return addLocalInstances(instances.toList())
+}
+
+/**
+ * Modifies the current [InterceptorContext] immutably using a builder pattern.
+ *
+ * @param block The configuration block to modify the [InterceptorContext.Builder].
+ * @return The modified [InterceptorContext] instance.
+ */
+public inline fun InterceptorContext.modify(
+ block: InterceptorContext.Builder.() -> Unit
+): InterceptorContext {
+ return builder().apply(block).build()
+}
\ No newline at end of file
diff --git a/common/core/src/commonMain/kotlin/org/timemates/rrpc/metadata/ClientMetadata.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/metadata/ClientMetadata.kt
new file mode 100644
index 0000000..962fe5e
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/metadata/ClientMetadata.kt
@@ -0,0 +1,40 @@
+@file:OptIn(ExperimentalSerializationApi::class)
+
+package org.timemates.rrpc.metadata
+
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.protobuf.ProtoNumber
+import kotlin.jvm.JvmField
+import kotlin.jvm.JvmOverloads
+import kotlin.jvm.JvmStatic
+
+/**
+ * Represents metadata information.
+ *
+ * @property serviceName The name of the service.
+ * @property procedureName The name of the procedure.
+ * @property extra Additional key-value pairs of metadata.
+ */
+@Serializable
+public data class ClientMetadata @JvmOverloads constructor(
+ @ProtoNumber(1)
+ public val schemaVersion: Int = RRpcMetadata.CURRENT_SCHEMA_VERSION,
+ @ProtoNumber(2)
+ public val serviceName: String = "",
+ @ProtoNumber(3)
+ public val procedureName: String = "",
+ @ProtoNumber(4)
+ public override val extra: ExtraMetadata = ExtraMetadata.EMPTY,
+) : RRpcMetadata {
+ public companion object {
+ @JvmField
+ public val EMPTY: ClientMetadata = ClientMetadata()
+ }
+
+ override fun extra(extra: ExtraMetadata): ClientMetadata {
+ return if (extra == this.extra)
+ this
+ else copy(extra = extra)
+ }
+}
\ No newline at end of file
diff --git a/common/core/src/commonMain/kotlin/org/timemates/rrpc/metadata/ExtraMetadata.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/metadata/ExtraMetadata.kt
new file mode 100644
index 0000000..ff8955b
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/metadata/ExtraMetadata.kt
@@ -0,0 +1,27 @@
+package org.timemates.rrpc.metadata
+
+import kotlinx.serialization.Serializable
+import org.timemates.rrpc.instances.InstanceContainer
+import org.timemates.rrpc.instances.ProvidableInstance
+import kotlin.jvm.JvmInline
+
+/**
+ * Represents extra metadata within incoming request associated with a coroutine context.
+ *
+ * @property extra The map containing the extra metadata.
+ */
+@JvmInline
+@Serializable
+public value class ExtraMetadata(public val extra: Map) : ProvidableInstance {
+ public companion object : ProvidableInstance.Key {
+ public val EMPTY: ExtraMetadata = ExtraMetadata(emptyMap())
+ }
+
+ override val key: ProvidableInstance.Key<*>
+ get() = Companion
+
+ public operator fun get(key: String): ByteArray? = extra[key]
+}
+
+public val InstanceContainer.extraMetadata: ExtraMetadata
+ get() = getInstance(ExtraMetadata) ?: error("Called from the wrong context")
\ No newline at end of file
diff --git a/common/core/src/commonMain/kotlin/org/timemates/rrpc/metadata/RSPMetadata.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/metadata/RSPMetadata.kt
new file mode 100644
index 0000000..56ee4cf
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/metadata/RSPMetadata.kt
@@ -0,0 +1,15 @@
+package org.timemates.rrpc.metadata
+
+public sealed interface RRpcMetadata {
+ public companion object {
+ /**
+ * This property implies the version of communication. It has
+ * different versioning from RRpc as such, only for global changes to the scheme.
+ */
+ public const val CURRENT_SCHEMA_VERSION: Int = 1
+ }
+
+ public val extra: ExtraMetadata
+
+ public fun extra(extra: ExtraMetadata): RRpcMetadata
+}
\ No newline at end of file
diff --git a/common/core/src/commonMain/kotlin/org/timemates/rrpc/metadata/ServerMetadata.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/metadata/ServerMetadata.kt
new file mode 100644
index 0000000..e6f5c47
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/metadata/ServerMetadata.kt
@@ -0,0 +1,29 @@
+@file:OptIn(ExperimentalSerializationApi::class)
+
+package org.timemates.rrpc.metadata
+
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.protobuf.ProtoNumber
+import kotlin.jvm.JvmField
+
+/**
+ * Represents metadata information that sent from Server to Client.
+ * @property extra Additional key-value pairs of metadata.
+ */
+@Serializable
+public data class ServerMetadata(
+ @ProtoNumber(1)
+ val schemaVersion: Int = RRpcMetadata.CURRENT_SCHEMA_VERSION,
+ @ProtoNumber(2)
+ public override val extra: ExtraMetadata = ExtraMetadata.EMPTY,
+) : RRpcMetadata {
+ public companion object {
+ @JvmField
+ public val EMPTY: ServerMetadata = ServerMetadata()
+ }
+
+ override fun extra(extra: ExtraMetadata): RRpcMetadata {
+ return copy(extra = extra)
+ }
+}
\ No newline at end of file
diff --git a/common/core/src/commonMain/kotlin/org/timemates/rrpc/options/Option.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/options/Option.kt
new file mode 100644
index 0000000..5f6beab
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/options/Option.kt
@@ -0,0 +1,39 @@
+package org.timemates.rrpc.options
+
+/**
+ * This interface represents [protobuf options](https://protobuf.dev/programming-guides/proto3/#options).
+ *
+ * **For now, RRpc supports only service and rpc options.**
+ *
+ * @property name the name assigned to the given option. It's used mostly for debugging
+ * and better understanding of the code. It has no actual connection with resolving
+ * options within requests or responses.
+ * @property tag unique identifier assigned to the given option.
+ */
+@Suppress("unused")
+public interface Option {
+ public val name: String
+ public val tag: Int
+}
+
+public data class ServiceOption(
+ override val name: String,
+ override val tag: Int,
+) : Option {
+ public companion object
+}
+
+public data class RPCOption(
+ override val name: String,
+ override val tag: Int,
+) : Option {
+ public companion object
+}
+
+public data class FileOption(
+ override val name: String,
+ override val tag: Int,
+) : Option {
+ public companion object
+}
+
diff --git a/common/core/src/commonMain/kotlin/org/timemates/rrpc/options/OptionsWithValue.kt b/common/core/src/commonMain/kotlin/org/timemates/rrpc/options/OptionsWithValue.kt
new file mode 100644
index 0000000..d81c658
--- /dev/null
+++ b/common/core/src/commonMain/kotlin/org/timemates/rrpc/options/OptionsWithValue.kt
@@ -0,0 +1,35 @@
+package org.timemates.rrpc.options
+
+import kotlin.jvm.JvmInline
+import kotlin.jvm.JvmStatic
+
+@JvmInline
+public value class OptionsWithValue(private val map: Map