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, Any>) { + public constructor(vararg pairs: Pair, Any>) : this(mapOf(*pairs)) + + public companion object { + @JvmStatic + public val EMPTY: OptionsWithValue = OptionsWithValue(emptyMap()) + } + + public operator fun get(option: Option): T? { + @Suppress("UNCHECKED_CAST") + return map[option] as? T + } + + public operator fun plus(other: OptionsWithValue): OptionsWithValue { + return OptionsWithValue(this.map + other.map) + } + + public operator fun plus(other: Pair, T>): OptionsWithValue { + return OptionsWithValue(this.map + (other.first to other.second)) + } + + public operator fun minus(option: Option<*>): OptionsWithValue { + return OptionsWithValue(map - option) + } + + public fun asMap(): Map, Any> = map +} + +public fun OptionsWithValue.hasOption(option: Option<*>): Boolean = get(option) != null \ No newline at end of file diff --git a/common/core/src/commonMain/resources/google/protobuf/any.proto b/common/core/src/commonMain/resources/google/protobuf/any.proto new file mode 100644 index 0000000..eff44e5 --- /dev/null +++ b/common/core/src/commonMain/resources/google/protobuf/any.proto @@ -0,0 +1,162 @@ +// Protocol Buffers - Google's data interchange format +// Copyright 2008 Google Inc. All rights reserved. +// https://developers.google.com/protocol-buffers/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +syntax = "proto3"; + +package google.protobuf; + +option go_package = "google.golang.org/protobuf/types/known/anypb"; +option java_package = "com.google.protobuf"; +option java_outer_classname = "AnyProto"; +option java_multiple_files = true; +option objc_class_prefix = "GPB"; +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; + +// `Any` contains an arbitrary serialized protocol buffer message along with a +// URL that describes the type of the serialized message. +// +// Protobuf library provides support to pack/unpack Any values in the form +// of utility functions or additional generated methods of the Any type. +// +// Example 1: Pack and unpack a message in C++. +// +// Foo foo = ...; +// Any any; +// any.PackFrom(foo); +// ... +// if (any.UnpackTo(&foo)) { +// ... +// } +// +// Example 2: Pack and unpack a message in Java. +// +// Foo foo = ...; +// Any any = Any.pack(foo); +// ... +// if (any.is(Foo.class)) { +// foo = any.unpack(Foo.class); +// } +// // or ... +// if (any.isSameTypeAs(Foo.getDefaultInstance())) { +// foo = any.unpack(Foo.getDefaultInstance()); +// } +// +// Example 3: Pack and unpack a message in Python. +// +// foo = Foo(...) +// any = Any() +// any.Pack(foo) +// ... +// if any.Is(Foo.DESCRIPTOR): +// any.Unpack(foo) +// ... +// +// Example 4: Pack and unpack a message in Go +// +// foo := &pb.Foo{...} +// any, err := anypb.New(foo) +// if err != nil { +// ... +// } +// ... +// foo := &pb.Foo{} +// if err := any.UnmarshalTo(foo); err != nil { +// ... +// } +// +// The pack methods provided by protobuf library will by default use +// 'type.googleapis.com/full.type.name' as the type URL and the unpack +// methods only use the fully qualified type name after the last '/' +// in the type URL, for example "foo.bar.com/x/y.z" will yield type +// name "y.z". +// +// JSON +// ==== +// The JSON representation of an `Any` value uses the regular +// representation of the deserialized, embedded message, with an +// additional field `@type` which contains the type URL. Example: +// +// package google.profile; +// message Person { +// string first_name = 1; +// string last_name = 2; +// } +// +// { +// "@type": "type.googleapis.com/google.profile.Person", +// "firstName": , +// "lastName": +// } +// +// If the embedded message type is well-known and has a custom JSON +// representation, that representation will be embedded adding a field +// `value` which holds the custom JSON in addition to the `@type` +// field. Example (for message [google.protobuf.Duration][]): +// +// { +// "@type": "type.googleapis.com/google.protobuf.Duration", +// "value": "1.212s" +// } +// +message Any { + // A URL/resource name that uniquely identifies the type of the serialized + // protocol buffer message. This string must contain at least + // one "/" character. The last segment of the URL's path must represent + // the fully qualified name of the type (as in + // `path/google.protobuf.Duration`). The name should be in a canonical form + // (e.g., leading "." is not accepted). + // + // In practice, teams usually precompile into the binary all types that they + // expect it to use in the context of Any. However, for URLs which use the + // scheme `http`, `https`, or no scheme, one can optionally set up a type + // server that maps type URLs to message definitions as follows: + // + // * If no scheme is provided, `https` is assumed. + // * An HTTP GET on the URL must yield a [google.protobuf.Type][] + // value in binary format, or produce an error. + // * Applications are allowed to cache lookup results based on the + // URL, or have them precompiled into a binary to avoid any + // lookup. Therefore, binary compatibility needs to be preserved + // on changes to types. (Use versioned type names to manage + // breaking changes.) + // + // Note: this functionality is not currently available in the official + // protobuf release, and it is not used for type URLs beginning with + // type.googleapis.com. As of May 2023, there are no widely used type server + // implementations and no plans to implement one. + // + // Schemes other than `http`, `https` (or the empty scheme) might be + // used with implementation specific semantics. + // + string type_url = 1; + + // Must be a valid serialized protocol buffer of the above specified type. + bytes value = 2; +} diff --git a/common/core/src/commonMain/resources/google/protobuf/descriptor.proto b/common/core/src/commonMain/resources/google/protobuf/descriptor.proto new file mode 100644 index 0000000..dfabac4 --- /dev/null +++ b/common/core/src/commonMain/resources/google/protobuf/descriptor.proto @@ -0,0 +1,1296 @@ +// Protocol Buffers - Google's data interchange format +// Copyright 2008 Google Inc. All rights reserved. +// https://developers.google.com/protocol-buffers/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +// Author: kenton@google.com (Kenton Varda) +// Based on original Protocol Buffers design by +// Sanjay Ghemawat, Jeff Dean, and others. +// +// The messages in this file describe the definitions found in .proto files. +// A valid .proto file can be translated directly to a FileDescriptorProto +// without any other information (e.g. without reading its imports). + +syntax = "proto2"; + +package google.protobuf; + +option go_package = "google.golang.org/protobuf/types/descriptorpb"; +option java_package = "com.google.protobuf"; +option java_outer_classname = "DescriptorProtos"; +option csharp_namespace = "Google.Protobuf.Reflection"; +option objc_class_prefix = "GPB"; +option cc_enable_arenas = true; + +// descriptor.proto must be optimized for speed because reflection-based +// algorithms don't work during bootstrapping. +option optimize_for = SPEED; + +// The protocol compiler can output a FileDescriptorSet containing the .proto +// files it parses. +message FileDescriptorSet { + repeated FileDescriptorProto file = 1; +} + +// The full set of known editions. +enum Edition { + // A placeholder for an unknown edition value. + EDITION_UNKNOWN = 0; + + // A placeholder edition for specifying default behaviors *before* a feature + // was first introduced. This is effectively an "infinite past". + EDITION_LEGACY = 900; + + // Legacy syntax "editions". These pre-date editions, but behave much like + // distinct editions. These can't be used to specify the edition of proto + // files, but feature definitions must supply proto2/proto3 defaults for + // backwards compatibility. + EDITION_PROTO2 = 998; + EDITION_PROTO3 = 999; + + // Editions that have been released. The specific values are arbitrary and + // should not be depended on, but they will always be time-ordered for easy + // comparison. + EDITION_2023 = 1000; + EDITION_2024 = 1001; + + // Placeholder editions for testing feature resolution. These should not be + // used or relyed on outside of tests. + EDITION_1_TEST_ONLY = 1; + EDITION_2_TEST_ONLY = 2; + EDITION_99997_TEST_ONLY = 99997; + EDITION_99998_TEST_ONLY = 99998; + EDITION_99999_TEST_ONLY = 99999; + + // Placeholder for specifying unbounded edition support. This should only + // ever be used by plugins that can expect to never require any changes to + // support a new edition. + EDITION_MAX = 0x7FFFFFFF; +} + +// Describes a complete .proto file. +message FileDescriptorProto { + optional string name = 1; // file name, relative to root of source tree + optional string package = 2; // e.g. "foo", "foo.bar", etc. + + // Names of files imported by this file. + repeated string dependency = 3; + // Indexes of the public imported files in the dependency list above. + repeated int32 public_dependency = 10; + // Indexes of the weak imported files in the dependency list. + // For Google-internal migration only. Do not use. + repeated int32 weak_dependency = 11; + + // All top-level definitions in this file. + repeated DescriptorProto message_type = 4; + repeated EnumDescriptorProto enum_type = 5; + repeated ServiceDescriptorProto service = 6; + repeated FieldDescriptorProto extension = 7; + + optional FileOptions options = 8; + + // This field contains optional information about the original source code. + // You may safely remove this entire field without harming runtime + // functionality of the descriptors -- the information is needed only by + // development tools. + optional SourceCodeInfo source_code_info = 9; + + // The syntax of the proto file. + // The supported values are "proto2", "proto3", and "editions". + // + // If `edition` is present, this value must be "editions". + optional string syntax = 12; + + // The edition of the proto file. + optional Edition edition = 14; +} + +// Describes a message type. +message DescriptorProto { + optional string name = 1; + + repeated FieldDescriptorProto field = 2; + repeated FieldDescriptorProto extension = 6; + + repeated DescriptorProto nested_type = 3; + repeated EnumDescriptorProto enum_type = 4; + + message ExtensionRange { + optional int32 start = 1; // Inclusive. + optional int32 end = 2; // Exclusive. + + optional ExtensionRangeOptions options = 3; + } + repeated ExtensionRange extension_range = 5; + + repeated OneofDescriptorProto oneof_decl = 8; + + optional MessageOptions options = 7; + + // Range of reserved tag numbers. Reserved tag numbers may not be used by + // fields or extension ranges in the same message. Reserved ranges may + // not overlap. + message ReservedRange { + optional int32 start = 1; // Inclusive. + optional int32 end = 2; // Exclusive. + } + repeated ReservedRange reserved_range = 9; + // Reserved field names, which may not be used by fields in the same message. + // A given name may only be reserved once. + repeated string reserved_name = 10; +} + +message ExtensionRangeOptions { + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + message Declaration { + // The extension number declared within the extension range. + optional int32 number = 1; + + // The fully-qualified name of the extension field. There must be a leading + // dot in front of the full name. + optional string full_name = 2; + + // The fully-qualified type name of the extension field. Unlike + // Metadata.type, Declaration.type must have a leading dot for messages + // and enums. + optional string type = 3; + + // If true, indicates that the number is reserved in the extension range, + // and any extension field with the number will fail to compile. Set this + // when a declared extension field is deleted. + optional bool reserved = 5; + + // If true, indicates that the extension must be defined as repeated. + // Otherwise the extension must be defined as optional. + optional bool repeated = 6; + + reserved 4; // removed is_repeated + } + + // For external users: DO NOT USE. We are in the process of open sourcing + // extension declaration and executing internal cleanups before it can be + // used externally. + repeated Declaration declaration = 2 [retention = RETENTION_SOURCE]; + + // Any features defined in the specific edition. + optional FeatureSet features = 50; + + // The verification state of the extension range. + enum VerificationState { + // All the extensions of the range must be declared. + DECLARATION = 0; + UNVERIFIED = 1; + } + + // The verification state of the range. + // TODO: flip the default to DECLARATION once all empty ranges + // are marked as UNVERIFIED. + optional VerificationState verification = 3 + [default = UNVERIFIED, retention = RETENTION_SOURCE]; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +// Describes a field within a message. +message FieldDescriptorProto { + enum Type { + // 0 is reserved for errors. + // Order is weird for historical reasons. + TYPE_DOUBLE = 1; + TYPE_FLOAT = 2; + // Not ZigZag encoded. Negative numbers take 10 bytes. Use TYPE_SINT64 if + // negative values are likely. + TYPE_INT64 = 3; + TYPE_UINT64 = 4; + // Not ZigZag encoded. Negative numbers take 10 bytes. Use TYPE_SINT32 if + // negative values are likely. + TYPE_INT32 = 5; + TYPE_FIXED64 = 6; + TYPE_FIXED32 = 7; + TYPE_BOOL = 8; + TYPE_STRING = 9; + // Tag-delimited aggregate. + // Group type is deprecated and not supported after google.protobuf. However, Proto3 + // implementations should still be able to parse the group wire format and + // treat group fields as unknown fields. In Editions, the group wire format + // can be enabled via the `message_encoding` feature. + TYPE_GROUP = 10; + TYPE_MESSAGE = 11; // Length-delimited aggregate. + + // New in version 2. + TYPE_BYTES = 12; + TYPE_UINT32 = 13; + TYPE_ENUM = 14; + TYPE_SFIXED32 = 15; + TYPE_SFIXED64 = 16; + TYPE_SINT32 = 17; // Uses ZigZag encoding. + TYPE_SINT64 = 18; // Uses ZigZag encoding. + } + + enum Label { + // 0 is reserved for errors + LABEL_OPTIONAL = 1; + LABEL_REPEATED = 3; + // The required label is only allowed in google.protobuf. In proto3 and Editions + // it's explicitly prohibited. In Editions, the `field_presence` feature + // can be used to get this behavior. + LABEL_REQUIRED = 2; + } + + optional string name = 1; + optional int32 number = 3; + optional Label label = 4; + + // If type_name is set, this need not be set. If both this and type_name + // are set, this must be one of TYPE_ENUM, TYPE_MESSAGE or TYPE_GROUP. + optional Type type = 5; + + // For message and enum types, this is the name of the type. If the name + // starts with a '.', it is fully-qualified. Otherwise, C++-like scoping + // rules are used to find the type (i.e. first the nested types within this + // message are searched, then within the parent, on up to the root + // namespace). + optional string type_name = 6; + + // For extensions, this is the name of the type being extended. It is + // resolved in the same manner as type_name. + optional string extendee = 2; + + // For numeric types, contains the original text representation of the value. + // For booleans, "true" or "false". + // For strings, contains the default text contents (not escaped in any way). + // For bytes, contains the C escaped value. All bytes >= 128 are escaped. + optional string default_value = 7; + + // If set, gives the index of a oneof in the containing type's oneof_decl + // list. This field is a member of that oneof. + optional int32 oneof_index = 9; + + // JSON name of this field. The value is set by protocol compiler. If the + // user has set a "json_name" option on this field, that option's value + // will be used. Otherwise, it's deduced from the field's name by converting + // it to camelCase. + optional string json_name = 10; + + optional FieldOptions options = 8; + + // If true, this is a proto3 "optional". When a proto3 field is optional, it + // tracks presence regardless of field type. + // + // When proto3_optional is true, this field must belong to a oneof to signal + // to old proto3 clients that presence is tracked for this field. This oneof + // is known as a "synthetic" oneof, and this field must be its sole member + // (each proto3 optional field gets its own synthetic oneof). Synthetic oneofs + // exist in the descriptor only, and do not generate any API. Synthetic oneofs + // must be ordered after all "real" oneofs. + // + // For message fields, proto3_optional doesn't create any semantic change, + // since non-repeated message fields always track presence. However it still + // indicates the semantic detail of whether the user wrote "optional" or not. + // This can be useful for round-tripping the .proto file. For consistency we + // give message fields a synthetic oneof also, even though it is not required + // to track presence. This is especially important because the parser can't + // tell if a field is a message or an enum, so it must always create a + // synthetic oneof. + // + // Proto2 optional fields do not set this flag, because they already indicate + // optional with `LABEL_OPTIONAL`. + optional bool proto3_optional = 17; +} + +// Describes a oneof. +message OneofDescriptorProto { + optional string name = 1; + optional OneofOptions options = 2; +} + +// Describes an enum type. +message EnumDescriptorProto { + optional string name = 1; + + repeated EnumValueDescriptorProto value = 2; + + optional EnumOptions options = 3; + + // Range of reserved numeric values. Reserved values may not be used by + // entries in the same enum. Reserved ranges may not overlap. + // + // Note that this is distinct from DescriptorProto.ReservedRange in that it + // is inclusive such that it can appropriately represent the entire int32 + // domain. + message EnumReservedRange { + optional int32 start = 1; // Inclusive. + optional int32 end = 2; // Inclusive. + } + + // Range of reserved numeric values. Reserved numeric values may not be used + // by enum values in the same enum declaration. Reserved ranges may not + // overlap. + repeated EnumReservedRange reserved_range = 4; + + // Reserved enum value names, which may not be reused. A given name may only + // be reserved once. + repeated string reserved_name = 5; +} + +// Describes a value within an enum. +message EnumValueDescriptorProto { + optional string name = 1; + optional int32 number = 2; + + optional EnumValueOptions options = 3; +} + +// Describes a service. +message ServiceDescriptorProto { + optional string name = 1; + repeated MethodDescriptorProto method = 2; + + optional ServiceOptions options = 3; +} + +// Describes a method of a service. +message MethodDescriptorProto { + optional string name = 1; + + // Input and output type names. These are resolved in the same way as + // FieldDescriptorProto.type_name, but must refer to a message type. + optional string input_type = 2; + optional string output_type = 3; + + optional MethodOptions options = 4; + + // Identifies if client streams multiple client messages + optional bool client_streaming = 5 [default = false]; + // Identifies if server streams multiple server messages + optional bool server_streaming = 6 [default = false]; +} + +// =================================================================== +// Options + +// Each of the definitions above may have "options" attached. These are +// just annotations which may cause code to be generated slightly differently +// or may contain hints for code that manipulates protocol messages. +// +// Clients may define custom options as extensions of the *Options messages. +// These extensions may not yet be known at parsing time, so the parser cannot +// store the values in them. Instead it stores them in a field in the *Options +// message called uninterpreted_option. This field must have the same name +// across all *Options messages. We then use this field to populate the +// extensions when we build a descriptor, at which point all protos have been +// parsed and so all extensions are known. +// +// Extension numbers for custom options may be chosen as follows: +// * For options which will only be used within a single application or +// organization, or for experimental options, use field numbers 50000 +// through 99999. It is up to you to ensure that you do not use the +// same number for multiple options. +// * For options which will be published and used publicly by multiple +// independent entities, e-mail protobuf-global-extension-registry@google.com +// to reserve extension numbers. Simply provide your project name (e.g. +// Objective-C plugin) and your project website (if available) -- there's no +// need to explain how you intend to use them. Usually you only need one +// extension number. You can declare multiple options with only one extension +// number by putting them in a sub-message. See the Custom Options section of +// the docs for examples: +// https://developers.google.com/protocol-buffers/docs/proto#options +// If this turns out to be popular, a web service will be set up +// to automatically assign option numbers. + +message FileOptions { + + // Sets the Java package where classes generated from this .proto will be + // placed. By default, the proto package is used, but this is often + // inappropriate because proto packages do not normally start with backwards + // domain names. + optional string java_package = 1; + + // Controls the name of the wrapper Java class generated for the .proto file. + // That class will always contain the .proto file's getDescriptor() method as + // well as any top-level extensions defined in the .proto file. + // If java_multiple_files is disabled, then all the other classes from the + // .proto file will be nested inside the single wrapper outer class. + optional string java_outer_classname = 8; + + // If enabled, then the Java code generator will generate a separate .java + // file for each top-level message, enum, and service defined in the .proto + // file. Thus, these types will *not* be nested inside the wrapper class + // named by java_outer_classname. However, the wrapper class will still be + // generated to contain the file's getDescriptor() method as well as any + // top-level extensions defined in the file. + optional bool java_multiple_files = 10 [default = false]; + + // This option does nothing. + optional bool java_generate_equals_and_hash = 20 [deprecated=true]; + + // A proto2 file can set this to true to opt in to UTF-8 checking for Java, + // which will throw an exception if invalid UTF-8 is parsed from the wire or + // assigned to a string field. + // + // TODO: clarify exactly what kinds of field types this option + // applies to, and update these docs accordingly. + // + // Proto3 files already perform these checks. Setting the option explicitly to + // false has no effect: it cannot be used to opt proto3 files out of UTF-8 + // checks. + optional bool java_string_check_utf8 = 27 [default = false]; + + // Generated classes can be optimized for speed or code size. + enum OptimizeMode { + SPEED = 1; // Generate complete code for parsing, serialization, + // etc. + CODE_SIZE = 2; // Use ReflectionOps to implement these methods. + LITE_RUNTIME = 3; // Generate code using MessageLite and the lite runtime. + } + optional OptimizeMode optimize_for = 9 [default = SPEED]; + + // Sets the Go package where structs generated from this .proto will be + // placed. If omitted, the Go package will be derived from the following: + // - The basename of the package import path, if provided. + // - Otherwise, the package statement in the .proto file, if present. + // - Otherwise, the basename of the .proto file, without extension. + optional string go_package = 11; + + // Should generic services be generated in each language? "Generic" services + // are not specific to any particular RPC system. They are generated by the + // main code generators in each language (without additional plugins). + // Generic services were the only kind of service generation supported by + // early versions of google.protobuf. + // + // Generic services are now considered deprecated in favor of using plugins + // that generate code specific to your particular RPC system. Therefore, + // these default to false. Old code which depends on generic services should + // explicitly set them to true. + optional bool cc_generic_services = 16 [default = false]; + optional bool java_generic_services = 17 [default = false]; + optional bool py_generic_services = 18 [default = false]; + reserved 42; // removed php_generic_services + reserved "php_generic_services"; + + // Is this file deprecated? + // Depending on the target platform, this can emit Deprecated annotations + // for everything in the file, or it will be completely ignored; in the very + // least, this is a formalization for deprecating files. + optional bool deprecated = 23 [default = false]; + + // Enables the use of arenas for the proto messages in this file. This applies + // only to generated classes for C++. + optional bool cc_enable_arenas = 31 [default = true]; + + // Sets the objective c class prefix which is prepended to all objective c + // generated classes from this .proto. There is no default. + optional string objc_class_prefix = 36; + + // Namespace for generated classes; defaults to the package. + optional string csharp_namespace = 37; + + // By default Swift generators will take the proto package and CamelCase it + // replacing '.' with underscore and use that to prefix the types/symbols + // defined. When this options is provided, they will use this value instead + // to prefix the types/symbols defined. + optional string swift_prefix = 39; + + // Sets the php class prefix which is prepended to all php generated classes + // from this .proto. Default is empty. + optional string php_class_prefix = 40; + + // Use this option to change the namespace of php generated classes. Default + // is empty. When this option is empty, the package name will be used for + // determining the namespace. + optional string php_namespace = 41; + + // Use this option to change the namespace of php generated metadata classes. + // Default is empty. When this option is empty, the proto file name will be + // used for determining the namespace. + optional string php_metadata_namespace = 44; + + // Use this option to change the package of ruby generated classes. Default + // is empty. When this option is not set, the package name will be used for + // determining the ruby package. + optional string ruby_package = 45; + + // Any features defined in the specific edition. + optional FeatureSet features = 50; + + // The parser stores options it doesn't recognize here. + // See the documentation for the "Options" section above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. + // See the documentation for the "Options" section above. + extensions 1000 to max; + + reserved 38; +} + +message MessageOptions { + // Set true to use the old proto1 MessageSet wire format for extensions. + // This is provided for backwards-compatibility with the MessageSet wire + // format. You should not use this for any other reason: It's less + // efficient, has fewer features, and is more complicated. + // + // The message must be defined exactly as follows: + // message Foo { + // option message_set_wire_format = true; + // extensions 4 to max; + // } + // Note that the message cannot have any defined fields; MessageSets only + // have extensions. + // + // All extensions of your type must be singular messages; e.g. they cannot + // be int32s, enums, or repeated messages. + // + // Because this is an option, the above two restrictions are not enforced by + // the protocol compiler. + optional bool message_set_wire_format = 1 [default = false]; + + // Disables the generation of the standard "descriptor()" accessor, which can + // conflict with a field of the same name. This is meant to make migration + // from proto1 easier; new code should avoid fields named "descriptor". + optional bool no_standard_descriptor_accessor = 2 [default = false]; + + // Is this message deprecated? + // Depending on the target platform, this can emit Deprecated annotations + // for the message, or it will be completely ignored; in the very least, + // this is a formalization for deprecating messages. + optional bool deprecated = 3 [default = false]; + + reserved 4, 5, 6; + + // Whether the message is an automatically generated map entry type for the + // maps field. + // + // For maps fields: + // map map_field = 1; + // The parsed descriptor looks like: + // message MapFieldEntry { + // option map_entry = true; + // optional KeyType key = 1; + // optional ValueType value = 2; + // } + // repeated MapFieldEntry map_field = 1; + // + // Implementations may choose not to generate the map_entry=true message, but + // use a native map in the target language to hold the keys and values. + // The reflection APIs in such implementations still need to work as + // if the field is a repeated message field. + // + // NOTE: Do not set the option in .proto files. Always use the maps syntax + // instead. The option should only be implicitly set by the proto compiler + // parser. + optional bool map_entry = 7; + + reserved 8; // javalite_serializable + reserved 9; // javanano_as_lite + + // Enable the legacy handling of JSON field name conflicts. This lowercases + // and strips underscored from the fields before comparison in proto3 only. + // The new behavior takes `json_name` into account and applies to proto2 as + // well. + // + // This should only be used as a temporary measure against broken builds due + // to the change in behavior for JSON field name conflicts. + // + // TODO This is legacy behavior we plan to remove once downstream + // teams have had time to migrate. + optional bool deprecated_legacy_json_field_conflicts = 11 [deprecated = true]; + + // Any features defined in the specific edition. + optional FeatureSet features = 12; + + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +message FieldOptions { + // NOTE: ctype is deprecated. Use `features.(pb.cpp).string_type` instead. + // The ctype option instructs the C++ code generator to use a different + // representation of the field than it normally would. See the specific + // options below. This option is only implemented to support use of + // [ctype=CORD] and [ctype=STRING] (the default) on non-repeated fields of + // type "bytes" in the open source release. + // TODO: make ctype actually deprecated. + optional CType ctype = 1 [/*deprecated = true,*/ default = STRING]; + enum CType { + // Default mode. + STRING = 0; + + // The option [ctype=CORD] may be applied to a non-repeated field of type + // "bytes". It indicates that in C++, the data should be stored in a Cord + // instead of a string. For very large strings, this may reduce memory + // fragmentation. It may also allow better performance when parsing from a + // Cord, or when parsing with aliasing enabled, as the parsed Cord may then + // alias the original buffer. + CORD = 1; + + STRING_PIECE = 2; + } + // The packed option can be enabled for repeated primitive fields to enable + // a more efficient representation on the wire. Rather than repeatedly + // writing the tag and type for each element, the entire array is encoded as + // a single length-delimited blob. In proto3, only explicit setting it to + // false will avoid using packed encoding. This option is prohibited in + // Editions, but the `repeated_field_encoding` feature can be used to control + // the behavior. + optional bool packed = 2; + + // The jstype option determines the JavaScript type used for values of the + // field. The option is permitted only for 64 bit integral and fixed types + // (int64, uint64, sint64, fixed64, sfixed64). A field with jstype JS_STRING + // is represented as JavaScript string, which avoids loss of precision that + // can happen when a large value is converted to a floating point JavaScript. + // Specifying JS_NUMBER for the jstype causes the generated JavaScript code to + // use the JavaScript "number" type. The behavior of the default option + // JS_NORMAL is implementation dependent. + // + // This option is an enum to permit additional types to be added, e.g. + // goog.math.Integer. + optional JSType jstype = 6 [default = JS_NORMAL]; + enum JSType { + // Use the default type. + JS_NORMAL = 0; + + // Use JavaScript strings. + JS_STRING = 1; + + // Use JavaScript numbers. + JS_NUMBER = 2; + } + + // Should this field be parsed lazily? Lazy applies only to message-type + // fields. It means that when the outer message is initially parsed, the + // inner message's contents will not be parsed but instead stored in encoded + // form. The inner message will actually be parsed when it is first accessed. + // + // This is only a hint. Implementations are free to choose whether to use + // eager or lazy parsing regardless of the value of this option. However, + // setting this option true suggests that the protocol author believes that + // using lazy parsing on this field is worth the additional bookkeeping + // overhead typically needed to implement it. + // + // This option does not affect the public interface of any generated code; + // all method signatures remain the same. Furthermore, thread-safety of the + // interface is not affected by this option; const methods remain safe to + // call from multiple threads concurrently, while non-const methods continue + // to require exclusive access. + // + // Note that lazy message fields are still eagerly verified to check + // ill-formed wireformat or missing required fields. Calling IsInitialized() + // on the outer message would fail if the inner message has missing required + // fields. Failed verification would result in parsing failure (except when + // uninitialized messages are acceptable). + optional bool lazy = 5 [default = false]; + + // unverified_lazy does no correctness checks on the byte stream. This should + // only be used where lazy with verification is prohibitive for performance + // reasons. + optional bool unverified_lazy = 15 [default = false]; + + // Is this field deprecated? + // Depending on the target platform, this can emit Deprecated annotations + // for accessors, or it will be completely ignored; in the very least, this + // is a formalization for deprecating fields. + optional bool deprecated = 3 [default = false]; + + // For Google-internal migration only. Do not use. + optional bool weak = 10 [default = false]; + + // Indicate that the field value should not be printed out when using debug + // formats, e.g. when the field contains sensitive credentials. + optional bool debug_redact = 16 [default = false]; + + // If set to RETENTION_SOURCE, the option will be omitted from the binary. + // Note: as of January 2023, support for this is in progress and does not yet + // have an effect (b/264593489). + enum OptionRetention { + RETENTION_UNKNOWN = 0; + RETENTION_RUNTIME = 1; + RETENTION_SOURCE = 2; + } + + optional OptionRetention retention = 17; + + // This indicates the types of entities that the field may apply to when used + // as an option. If it is unset, then the field may be freely used as an + // option on any kind of entity. Note: as of January 2023, support for this is + // in progress and does not yet have an effect (b/264593489). + enum OptionTargetType { + TARGET_TYPE_UNKNOWN = 0; + TARGET_TYPE_FILE = 1; + TARGET_TYPE_EXTENSION_RANGE = 2; + TARGET_TYPE_MESSAGE = 3; + TARGET_TYPE_FIELD = 4; + TARGET_TYPE_ONEOF = 5; + TARGET_TYPE_ENUM = 6; + TARGET_TYPE_ENUM_ENTRY = 7; + TARGET_TYPE_SERVICE = 8; + TARGET_TYPE_METHOD = 9; + } + + repeated OptionTargetType targets = 19; + + message EditionDefault { + optional Edition edition = 3; + optional string value = 2; // Textproto value. + } + repeated EditionDefault edition_defaults = 20; + + // Any features defined in the specific edition. + optional FeatureSet features = 21; + + // Information about the support window of a feature. + message FeatureSupport { + // The edition that this feature was first available in. In editions + // earlier than this one, the default assigned to EDITION_LEGACY will be + // used, and proto files will not be able to override it. + optional Edition edition_introduced = 1; + + // The edition this feature becomes deprecated in. Using this after this + // edition may trigger warnings. + optional Edition edition_deprecated = 2; + + // The deprecation warning text if this feature is used after the edition it + // was marked deprecated in. + optional string deprecation_warning = 3; + + // The edition this feature is no longer available in. In editions after + // this one, the last default assigned will be used, and proto files will + // not be able to override it. + optional Edition edition_removed = 4; + } + optional FeatureSupport feature_support = 22; + + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; + + reserved 4; // removed jtype + reserved 18; // reserve target, target_obsolete_do_not_use +} + +message OneofOptions { + // Any features defined in the specific edition. + optional FeatureSet features = 1; + + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +message EnumOptions { + + // Set this option to true to allow mapping different tag names to the same + // value. + optional bool allow_alias = 2; + + // Is this enum deprecated? + // Depending on the target platform, this can emit Deprecated annotations + // for the enum, or it will be completely ignored; in the very least, this + // is a formalization for deprecating enums. + optional bool deprecated = 3 [default = false]; + + reserved 5; // javanano_as_lite + + // Enable the legacy handling of JSON field name conflicts. This lowercases + // and strips underscored from the fields before comparison in proto3 only. + // The new behavior takes `json_name` into account and applies to proto2 as + // well. + // TODO Remove this legacy behavior once downstream teams have + // had time to migrate. + optional bool deprecated_legacy_json_field_conflicts = 6 [deprecated = true]; + + // Any features defined in the specific edition. + optional FeatureSet features = 7; + + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +message EnumValueOptions { + // Is this enum value deprecated? + // Depending on the target platform, this can emit Deprecated annotations + // for the enum value, or it will be completely ignored; in the very least, + // this is a formalization for deprecating enum values. + optional bool deprecated = 1 [default = false]; + + // Any features defined in the specific edition. + optional FeatureSet features = 2; + + // Indicate that fields annotated with this enum value should not be printed + // out when using debug formats, e.g. when the field contains sensitive + // credentials. + optional bool debug_redact = 3 [default = false]; + + // Information about the support window of a feature value. + optional FieldOptions.FeatureSupport feature_support = 4; + + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +message ServiceOptions { + + // Any features defined in the specific edition. + optional FeatureSet features = 34; + + // Note: Field numbers 1 through 32 are reserved for Google's internal RPC + // framework. We apologize for hoarding these numbers to ourselves, but + // we were already using them long before we decided to release Protocol + // Buffers. + + // Is this service deprecated? + // Depending on the target platform, this can emit Deprecated annotations + // for the service, or it will be completely ignored; in the very least, + // this is a formalization for deprecating services. + optional bool deprecated = 33 [default = false]; + + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +message MethodOptions { + + // Note: Field numbers 1 through 32 are reserved for Google's internal RPC + // framework. We apologize for hoarding these numbers to ourselves, but + // we were already using them long before we decided to release Protocol + // Buffers. + + // Is this method deprecated? + // Depending on the target platform, this can emit Deprecated annotations + // for the method, or it will be completely ignored; in the very least, + // this is a formalization for deprecating methods. + optional bool deprecated = 33 [default = false]; + + // Is this method side-effect-free (or safe in HTTP parlance), or idempotent, + // or neither? HTTP based RPC implementation may choose GET verb for safe + // methods, and PUT verb for idempotent methods instead of the default POST. + enum IdempotencyLevel { + IDEMPOTENCY_UNKNOWN = 0; + NO_SIDE_EFFECTS = 1; // implies idempotent + IDEMPOTENT = 2; // idempotent, but may have side effects + } + optional IdempotencyLevel idempotency_level = 34 + [default = IDEMPOTENCY_UNKNOWN]; + + // Any features defined in the specific edition. + optional FeatureSet features = 35; + + // The parser stores options it doesn't recognize here. See above. + repeated UninterpretedOption uninterpreted_option = 999; + + // Clients can define custom options in extensions of this message. See above. + extensions 1000 to max; +} + +// A message representing a option the parser does not recognize. This only +// appears in options protos created by the compiler::Parser class. +// DescriptorPool resolves these when building Descriptor objects. Therefore, +// options protos in descriptor objects (e.g. returned by Descriptor::options(), +// or produced by Descriptor::CopyTo()) will never have UninterpretedOptions +// in them. +message UninterpretedOption { + // The name of the uninterpreted option. Each string represents a segment in + // a dot-separated name. is_extension is true iff a segment represents an + // extension (denoted with parentheses in options specs in .proto files). + // E.g.,{ ["foo", false], ["bar.baz", true], ["moo", false] } represents + // "foo.(bar.baz).moo". + message NamePart { + required string name_part = 1; + required bool is_extension = 2; + } + repeated NamePart name = 2; + + // The value of the uninterpreted option, in whatever type the tokenizer + // identified it as during parsing. Exactly one of these should be set. + optional string identifier_value = 3; + optional uint64 positive_int_value = 4; + optional int64 negative_int_value = 5; + optional double double_value = 6; + optional bytes string_value = 7; + optional string aggregate_value = 8; +} + +// =================================================================== +// Features + +// TODO Enums in C++ gencode (and potentially other languages) are +// not well scoped. This means that each of the feature enums below can clash +// with each other. The short names we've chosen maximize call-site +// readability, but leave us very open to this scenario. A future feature will +// be designed and implemented to handle this, hopefully before we ever hit a +// conflict here. +message FeatureSet { + enum FieldPresence { + FIELD_PRESENCE_UNKNOWN = 0; + EXPLICIT = 1; + IMPLICIT = 2; + LEGACY_REQUIRED = 3; + } + optional FieldPresence field_presence = 1 [ + retention = RETENTION_RUNTIME, + targets = TARGET_TYPE_FIELD, + targets = TARGET_TYPE_FILE, + feature_support = { + edition_introduced: EDITION_2023, + }, + edition_defaults = { edition: EDITION_LEGACY, value: "EXPLICIT" }, + edition_defaults = { edition: EDITION_PROTO3, value: "IMPLICIT" }, + edition_defaults = { edition: EDITION_2023, value: "EXPLICIT" } + ]; + + enum EnumType { + ENUM_TYPE_UNKNOWN = 0; + OPEN = 1; + CLOSED = 2; + } + optional EnumType enum_type = 2 [ + retention = RETENTION_RUNTIME, + targets = TARGET_TYPE_ENUM, + targets = TARGET_TYPE_FILE, + feature_support = { + edition_introduced: EDITION_2023, + }, + edition_defaults = { edition: EDITION_LEGACY, value: "CLOSED" }, + edition_defaults = { edition: EDITION_PROTO3, value: "OPEN" } + ]; + + enum RepeatedFieldEncoding { + REPEATED_FIELD_ENCODING_UNKNOWN = 0; + PACKED = 1; + EXPANDED = 2; + } + optional RepeatedFieldEncoding repeated_field_encoding = 3 [ + retention = RETENTION_RUNTIME, + targets = TARGET_TYPE_FIELD, + targets = TARGET_TYPE_FILE, + feature_support = { + edition_introduced: EDITION_2023, + }, + edition_defaults = { edition: EDITION_LEGACY, value: "EXPANDED" }, + edition_defaults = { edition: EDITION_PROTO3, value: "PACKED" } + ]; + + enum Utf8Validation { + UTF8_VALIDATION_UNKNOWN = 0; + VERIFY = 2; + NONE = 3; + reserved 1; + } + optional Utf8Validation utf8_validation = 4 [ + retention = RETENTION_RUNTIME, + targets = TARGET_TYPE_FIELD, + targets = TARGET_TYPE_FILE, + feature_support = { + edition_introduced: EDITION_2023, + }, + edition_defaults = { edition: EDITION_LEGACY, value: "NONE" }, + edition_defaults = { edition: EDITION_PROTO3, value: "VERIFY" } + ]; + + enum MessageEncoding { + MESSAGE_ENCODING_UNKNOWN = 0; + LENGTH_PREFIXED = 1; + DELIMITED = 2; + } + optional MessageEncoding message_encoding = 5 [ + retention = RETENTION_RUNTIME, + targets = TARGET_TYPE_FIELD, + targets = TARGET_TYPE_FILE, + feature_support = { + edition_introduced: EDITION_2023, + }, + edition_defaults = { edition: EDITION_LEGACY, value: "LENGTH_PREFIXED" } + ]; + + enum JsonFormat { + JSON_FORMAT_UNKNOWN = 0; + ALLOW = 1; + LEGACY_BEST_EFFORT = 2; + } + optional JsonFormat json_format = 6 [ + retention = RETENTION_RUNTIME, + targets = TARGET_TYPE_MESSAGE, + targets = TARGET_TYPE_ENUM, + targets = TARGET_TYPE_FILE, + feature_support = { + edition_introduced: EDITION_2023, + }, + edition_defaults = { edition: EDITION_LEGACY, value: "LEGACY_BEST_EFFORT" }, + edition_defaults = { edition: EDITION_PROTO3, value: "ALLOW" } + ]; + + reserved 999; + + extensions 1000 to 9994 [ + declaration = { + number: 1000, + full_name: ".pb.cpp", + type: ".pb.CppFeatures" + }, + declaration = { + number: 1001, + full_name: ".pb.java", + type: ".pb.JavaFeatures" + }, + declaration = { number: 1002, full_name: ".pb.go", type: ".pb.GoFeatures" }, + declaration = { + number: 9990, + full_name: ".pb.proto1", + type: ".pb.Proto1Features" + } + ]; + + extensions 9995 to 9999; // For internal testing + extensions 10000; // for https://github.com/bufbuild/protobuf-es +} + +// A compiled specification for the defaults of a set of features. These +// messages are generated from FeatureSet extensions and can be used to seed +// feature resolution. The resolution with this object becomes a simple search +// for the closest matching edition, followed by proto merges. +message FeatureSetDefaults { + // A map from every known edition with a unique set of defaults to its + // defaults. Not all editions may be contained here. For a given edition, + // the defaults at the closest matching edition ordered at or before it should + // be used. This field must be in strict ascending order by edition. + message FeatureSetEditionDefault { + optional Edition edition = 3; + + // Defaults of features that can be overridden in this edition. + optional FeatureSet overridable_features = 4; + + // Defaults of features that can't be overridden in this edition. + optional FeatureSet fixed_features = 5; + + reserved 1, 2; + reserved "features"; + } + repeated FeatureSetEditionDefault defaults = 1; + + // The minimum supported edition (inclusive) when this was constructed. + // Editions before this will not have defaults. + optional Edition minimum_edition = 4; + + // The maximum known edition (inclusive) when this was constructed. Editions + // after this will not have reliable defaults. + optional Edition maximum_edition = 5; +} + +// =================================================================== +// Optional source code info + +// Encapsulates information about the original source file from which a +// FileDescriptorProto was generated. +message SourceCodeInfo { + // A Location identifies a piece of source code in a .proto file which + // corresponds to a particular definition. This information is intended + // to be useful to IDEs, code indexers, documentation generators, and similar + // tools. + // + // For example, say we have a file like: + // message Foo { + // optional string foo = 1; + // } + // Let's look at just the field definition: + // optional string foo = 1; + // ^ ^^ ^^ ^ ^^^ + // a bc de f ghi + // We have the following locations: + // span path represents + // [a,i) [ 4, 0, 2, 0 ] The whole field definition. + // [a,b) [ 4, 0, 2, 0, 4 ] The label (optional). + // [c,d) [ 4, 0, 2, 0, 5 ] The type (string). + // [e,f) [ 4, 0, 2, 0, 1 ] The name (foo). + // [g,h) [ 4, 0, 2, 0, 3 ] The number (1). + // + // Notes: + // - A location may refer to a repeated field itself (i.e. not to any + // particular index within it). This is used whenever a set of elements are + // logically enclosed in a single code segment. For example, an entire + // extend block (possibly containing multiple extension definitions) will + // have an outer location whose path refers to the "extensions" repeated + // field without an index. + // - Multiple locations may have the same path. This happens when a single + // logical declaration is spread out across multiple places. The most + // obvious example is the "extend" block again -- there may be multiple + // extend blocks in the same scope, each of which will have the same path. + // - A location's span is not always a subset of its parent's span. For + // example, the "extendee" of an extension declaration appears at the + // beginning of the "extend" block and is shared by all extensions within + // the block. + // - Just because a location's span is a subset of some other location's span + // does not mean that it is a descendant. For example, a "group" defines + // both a type and a field in a single declaration. Thus, the locations + // corresponding to the type and field and their components will overlap. + // - Code which tries to interpret locations should probably be designed to + // ignore those that it doesn't understand, as more types of locations could + // be recorded in the future. + repeated Location location = 1; + message Location { + // Identifies which part of the FileDescriptorProto was defined at this + // location. + // + // Each element is a field number or an index. They form a path from + // the root FileDescriptorProto to the place where the definition appears. + // For example, this path: + // [ 4, 3, 2, 7, 1 ] + // refers to: + // file.message_type(3) // 4, 3 + // .field(7) // 2, 7 + // .name() // 1 + // This is because FileDescriptorProto.message_type has field number 4: + // repeated DescriptorProto message_type = 4; + // and DescriptorProto.field has field number 2: + // repeated FieldDescriptorProto field = 2; + // and FieldDescriptorProto.name has field number 1: + // optional string name = 1; + // + // Thus, the above path gives the location of a field name. If we removed + // the last element: + // [ 4, 3, 2, 7 ] + // this path refers to the whole field declaration (from the beginning + // of the label to the terminating semicolon). + repeated int32 path = 1 [packed = true]; + + // Always has exactly three or four elements: start line, start column, + // end line (optional, otherwise assumed same as start line), end column. + // These are packed into a single field for efficiency. Note that line + // and column numbers are zero-based -- typically you will want to add + // 1 to each before displaying to a user. + repeated int32 span = 2 [packed = true]; + + // If this SourceCodeInfo represents a complete declaration, these are any + // comments appearing before and after the declaration which appear to be + // attached to the declaration. + // + // A series of line comments appearing on consecutive lines, with no other + // tokens appearing on those lines, will be treated as a single comment. + // + // leading_detached_comments will keep paragraphs of comments that appear + // before (but not connected to) the current element. Each paragraph, + // separated by empty lines, will be one comment element in the repeated + // field. + // + // Only the comment content is provided; comment markers (e.g. //) are + // stripped out. For block comments, leading whitespace and an asterisk + // will be stripped from the beginning of each line other than the first. + // Newlines are included in the output. + // + // Examples: + // + // optional int32 foo = 1; // Comment attached to foo. + // // Comment attached to bar. + // optional int32 bar = 2; + // + // optional string baz = 3; + // // Comment attached to baz. + // // Another line attached to baz. + // + // // Comment attached to moo. + // // + // // Another line attached to moo. + // optional double moo = 4; + // + // // Detached comment for corge. This is not leading or trailing comments + // // to moo or corge because there are blank lines separating it from + // // both. + // + // // Detached comment for corge paragraph 2. + // + // optional string corge = 5; + // /* Block comment attached + // * to corge. Leading asterisks + // * will be removed. */ + // /* Block comment attached to + // * grault. */ + // optional int32 grault = 6; + // + // // ignored detached comments. + optional string leading_comments = 3; + optional string trailing_comments = 4; + repeated string leading_detached_comments = 6; + } +} + +// Describes the relationship between generated code and its original source +// file. A GeneratedCodeInfo message is associated with only one generated +// source file, but may contain references to different source .proto files. +message GeneratedCodeInfo { + // An Annotation connects some span of text in generated code to an element + // of its generating .proto file. + repeated Annotation annotation = 1; + message Annotation { + // Identifies the element in the original source .proto file. This field + // is formatted the same as SourceCodeInfo.Location.path. + repeated int32 path = 1 [packed = true]; + + // Identifies the filesystem path to the original source .proto. + optional string source_file = 2; + + // Identifies the starting offset in bytes in the generated code + // that relates to the identified object. + optional int32 begin = 3; + + // Identifies the ending offset in bytes in the generated code that + // relates to the identified object. The end offset should be one past + // the last relevant byte (so the length of the text = end - begin). + optional int32 end = 4; + + // Represents the identified object's effect on the element in the original + // .proto file. + enum Semantic { + // There is no effect or the effect is indescribable. + NONE = 0; + // The element is set or otherwise mutated. + SET = 1; + // An alias to the element is returned. + ALIAS = 2; + } + optional Semantic semantic = 5; + } +} diff --git a/common/core/src/commonMain/resources/google/protobuf/duration.proto b/common/core/src/commonMain/resources/google/protobuf/duration.proto new file mode 100644 index 0000000..41f40c2 --- /dev/null +++ b/common/core/src/commonMain/resources/google/protobuf/duration.proto @@ -0,0 +1,115 @@ +// Protocol Buffers - Google's data interchange format +// Copyright 2008 Google Inc. All rights reserved. +// https://developers.google.com/protocol-buffers/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +syntax = "proto3"; + +package google.protobuf; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/durationpb"; +option java_package = "com.google.protobuf"; +option java_outer_classname = "DurationProto"; +option java_multiple_files = true; +option objc_class_prefix = "GPB"; +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; + +// A Duration represents a signed, fixed-length span of time represented +// as a count of seconds and fractions of seconds at nanosecond +// resolution. It is independent of any calendar and concepts like "day" +// or "month". It is related to Timestamp in that the difference between +// two Timestamp values is a Duration and it can be added or subtracted +// from a Timestamp. Range is approximately +-10,000 years. +// +// # Examples +// +// Example 1: Compute Duration from two Timestamps in pseudo code. +// +// Timestamp start = ...; +// Timestamp end = ...; +// Duration duration = ...; +// +// duration.seconds = end.seconds - start.seconds; +// duration.nanos = end.nanos - start.nanos; +// +// if (duration.seconds < 0 && duration.nanos > 0) { +// duration.seconds += 1; +// duration.nanos -= 1000000000; +// } else if (duration.seconds > 0 && duration.nanos < 0) { +// duration.seconds -= 1; +// duration.nanos += 1000000000; +// } +// +// Example 2: Compute Timestamp from Timestamp + Duration in pseudo code. +// +// Timestamp start = ...; +// Duration duration = ...; +// Timestamp end = ...; +// +// end.seconds = start.seconds + duration.seconds; +// end.nanos = start.nanos + duration.nanos; +// +// if (end.nanos < 0) { +// end.seconds -= 1; +// end.nanos += 1000000000; +// } else if (end.nanos >= 1000000000) { +// end.seconds += 1; +// end.nanos -= 1000000000; +// } +// +// Example 3: Compute Duration from datetime.timedelta in Python. +// +// td = datetime.timedelta(days=3, minutes=10) +// duration = Duration() +// duration.FromTimedelta(td) +// +// # JSON Mapping +// +// In JSON format, the Duration type is encoded as a string rather than an +// object, where the string ends in the suffix "s" (indicating seconds) and +// is preceded by the number of seconds, with nanoseconds expressed as +// fractional seconds. For example, 3 seconds with 0 nanoseconds should be +// encoded in JSON format as "3s", while 3 seconds and 1 nanosecond should +// be expressed in JSON format as "3.000000001s", and 3 seconds and 1 +// microsecond should be expressed in JSON format as "3.000001s". +// +message Duration { + // Signed seconds of the span of time. Must be from -315,576,000,000 + // to +315,576,000,000 inclusive. Note: these bounds are computed from: + // 60 sec/min * 60 min/hr * 24 hr/day * 365.25 days/year * 10000 years + int64 seconds = 1; + + // Signed fractions of a second at nanosecond resolution of the span + // of time. Durations less than one second are represented with a 0 + // `seconds` field and a positive or negative `nanos` field. For durations + // of one second or more, a non-zero value for the `nanos` field must be + // of the same sign as the `seconds` field. Must be from -999,999,999 + // to +999,999,999 inclusive. + int32 nanos = 2; +} diff --git a/common/core/src/commonMain/resources/google/protobuf/empty.proto b/common/core/src/commonMain/resources/google/protobuf/empty.proto new file mode 100644 index 0000000..b87c89d --- /dev/null +++ b/common/core/src/commonMain/resources/google/protobuf/empty.proto @@ -0,0 +1,51 @@ +// Protocol Buffers - Google's data interchange format +// Copyright 2008 Google Inc. All rights reserved. +// https://developers.google.com/protocol-buffers/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +syntax = "proto3"; + +package google.protobuf; + +option go_package = "google.golang.org/protobuf/types/known/emptypb"; +option java_package = "com.google.protobuf"; +option java_outer_classname = "EmptyProto"; +option java_multiple_files = true; +option objc_class_prefix = "GPB"; +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option cc_enable_arenas = true; + +// A generic empty message that you can re-use to avoid defining duplicated +// empty messages in your APIs. A typical example is to use it as the request +// or the response type of an API method. For instance: +// +// service Foo { +// rpc Bar(google.protobuf.Empty) returns (google.protobuf.Empty); +// } +// +message Empty {} diff --git a/common/core/src/commonMain/resources/google/protobuf/struct.proto b/common/core/src/commonMain/resources/google/protobuf/struct.proto new file mode 100644 index 0000000..1bf0c1a --- /dev/null +++ b/common/core/src/commonMain/resources/google/protobuf/struct.proto @@ -0,0 +1,95 @@ +// Protocol Buffers - Google's data interchange format +// Copyright 2008 Google Inc. All rights reserved. +// https://developers.google.com/protocol-buffers/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +syntax = "proto3"; + +package google.protobuf; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/structpb"; +option java_package = "com.google.protobuf"; +option java_outer_classname = "StructProto"; +option java_multiple_files = true; +option objc_class_prefix = "GPB"; +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; + +// `Struct` represents a structured data value, consisting of fields +// which map to dynamically typed values. In some languages, `Struct` +// might be supported by a native representation. For example, in +// scripting languages like JS a struct is represented as an +// object. The details of that representation are described together +// with the proto support for the language. +// +// The JSON representation for `Struct` is JSON object. +message Struct { + // Unordered map of dynamically typed values. + map fields = 1; +} + +// `Value` represents a dynamically typed value which can be either +// null, a number, a string, a boolean, a recursive struct value, or a +// list of values. A producer of value is expected to set one of these +// variants. Absence of any variant indicates an error. +// +// The JSON representation for `Value` is JSON value. +message Value { + // The kind of value. + oneof kind { + // Represents a null value. + NullValue null_value = 1; + // Represents a double value. + double number_value = 2; + // Represents a string value. + string string_value = 3; + // Represents a boolean value. + bool bool_value = 4; + // Represents a structured value. + Struct struct_value = 5; + // Represents a repeated `Value`. + ListValue list_value = 6; + } +} + +// `NullValue` is a singleton enumeration to represent the null value for the +// `Value` type union. +// +// The JSON representation for `NullValue` is JSON `null`. +enum NullValue { + // Null value. + NULL_VALUE = 0; +} + +// `ListValue` is a wrapper around a repeated field of values. +// +// The JSON representation for `ListValue` is JSON array. +message ListValue { + // Repeated field of dynamically typed values. + repeated Value values = 1; +} diff --git a/common/core/src/commonMain/resources/google/protobuf/timestamp.proto b/common/core/src/commonMain/resources/google/protobuf/timestamp.proto new file mode 100644 index 0000000..fd0bc07 --- /dev/null +++ b/common/core/src/commonMain/resources/google/protobuf/timestamp.proto @@ -0,0 +1,144 @@ +// Protocol Buffers - Google's data interchange format +// Copyright 2008 Google Inc. All rights reserved. +// https://developers.google.com/protocol-buffers/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +syntax = "proto3"; + +package google.protobuf; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/timestamppb"; +option java_package = "com.google.protobuf"; +option java_outer_classname = "TimestampProto"; +option java_multiple_files = true; +option objc_class_prefix = "GPB"; +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; + +// A Timestamp represents a point in time independent of any time zone or local +// calendar, encoded as a count of seconds and fractions of seconds at +// nanosecond resolution. The count is relative to an epoch at UTC midnight on +// January 1, 1970, in the proleptic Gregorian calendar which extends the +// Gregorian calendar backwards to year one. +// +// All minutes are 60 seconds long. Leap seconds are "smeared" so that no leap +// second table is needed for interpretation, using a [24-hour linear +// smear](https://developers.google.com/time/smear). +// +// The range is from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. By +// restricting to that range, we ensure that we can convert to and from [RFC +// 3339](https://www.ietf.org/rfc/rfc3339.txt) date strings. +// +// # Examples +// +// Example 1: Compute Timestamp from POSIX `time()`. +// +// Timestamp timestamp; +// timestamp.set_seconds(time(NULL)); +// timestamp.set_nanos(0); +// +// Example 2: Compute Timestamp from POSIX `gettimeofday()`. +// +// struct timeval tv; +// gettimeofday(&tv, NULL); +// +// Timestamp timestamp; +// timestamp.set_seconds(tv.tv_sec); +// timestamp.set_nanos(tv.tv_usec * 1000); +// +// Example 3: Compute Timestamp from Win32 `GetSystemTimeAsFileTime()`. +// +// FILETIME ft; +// GetSystemTimeAsFileTime(&ft); +// UINT64 ticks = (((UINT64)ft.dwHighDateTime) << 32) | ft.dwLowDateTime; +// +// // A Windows tick is 100 nanoseconds. Windows epoch 1601-01-01T00:00:00Z +// // is 11644473600 seconds before Unix epoch 1970-01-01T00:00:00Z. +// Timestamp timestamp; +// timestamp.set_seconds((INT64) ((ticks / 10000000) - 11644473600LL)); +// timestamp.set_nanos((INT32) ((ticks % 10000000) * 100)); +// +// Example 4: Compute Timestamp from Java `System.currentTimeMillis()`. +// +// long millis = System.currentTimeMillis(); +// +// Timestamp timestamp = Timestamp.newBuilder().setSeconds(millis / 1000) +// .setNanos((int) ((millis % 1000) * 1000000)).build(); +// +// Example 5: Compute Timestamp from Java `Instant.now()`. +// +// Instant now = Instant.now(); +// +// Timestamp timestamp = +// Timestamp.newBuilder().setSeconds(now.getEpochSecond()) +// .setNanos(now.getNano()).build(); +// +// Example 6: Compute Timestamp from current time in Python. +// +// timestamp = Timestamp() +// timestamp.GetCurrentTime() +// +// # JSON Mapping +// +// In JSON format, the Timestamp type is encoded as a string in the +// [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. That is, the +// format is "{year}-{month}-{day}T{hour}:{min}:{sec}[.{frac_sec}]Z" +// where {year} is always expressed using four digits while {month}, {day}, +// {hour}, {min}, and {sec} are zero-padded to two digits each. The fractional +// seconds, which can go up to 9 digits (i.e. up to 1 nanosecond resolution), +// are optional. The "Z" suffix indicates the timezone ("UTC"); the timezone +// is required. A proto3 JSON serializer should always use UTC (as indicated by +// "Z") when printing the Timestamp type and a proto3 JSON parser should be +// able to accept both UTC and other timezones (as indicated by an offset). +// +// For example, "2017-01-15T01:30:15.01Z" encodes 15.01 seconds past +// 01:30 UTC on January 15, 2017. +// +// In JavaScript, one can convert a Date object to this format using the +// standard +// [toISOString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString) +// method. In Python, a standard `datetime.datetime` object can be converted +// to this format using +// [`strftime`](https://docs.python.org/2/library/time.html#time.strftime) with +// the time format spec '%Y-%m-%dT%H:%M:%S.%fZ'. Likewise, in Java, one can use +// the Joda Time's [`ISODateTimeFormat.dateTime()`]( +// http://joda-time.sourceforge.net/apidocs/org/joda/time/format/ISODateTimeFormat.html#dateTime() +// ) to obtain a formatter capable of generating timestamps in this format. +// +message Timestamp { + // Represents seconds of UTC time since Unix epoch + // 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to + // 9999-12-31T23:59:59Z inclusive. + int64 seconds = 1; + + // Non-negative fractions of a second at nanosecond resolution. Negative + // second values with fractions must still have non-negative nanos values + // that count forward in time. Must be from 0 to 999,999,999 + // inclusive. + int32 nanos = 2; +} diff --git a/common/core/src/commonMain/resources/google/protobuf/wrappers.proto b/common/core/src/commonMain/resources/google/protobuf/wrappers.proto new file mode 100644 index 0000000..452a316 --- /dev/null +++ b/common/core/src/commonMain/resources/google/protobuf/wrappers.proto @@ -0,0 +1,123 @@ +// Protocol Buffers - Google's data interchange format +// Copyright 2008 Google Inc. All rights reserved. +// https://developers.google.com/protocol-buffers/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// +// Wrappers for primitive (non-message) types. These types are useful +// for embedding primitives in the `google.protobuf.Any` type and for places +// where we need to distinguish between the absence of a primitive +// typed field and its default value. +// +// These wrappers have no meaningful use within repeated fields as they lack +// the ability to detect presence on individual elements. +// These wrappers have no meaningful use within a map or a oneof since +// individual entries of a map or fields of a oneof can already detect presence. + +syntax = "proto3"; + +package google.protobuf; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/wrapperrpcb"; +option java_package = "com.google.protobuf"; +option java_outer_classname = "WrappeRRpc"; +option java_multiple_files = true; +option objc_class_prefix = "GPB"; +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; + +// Wrapper message for `double`. +// +// The JSON representation for `DoubleValue` is JSON number. +message DoubleValue { + // The double value. + double value = 1; +} + +// Wrapper message for `float`. +// +// The JSON representation for `FloatValue` is JSON number. +message FloatValue { + // The float value. + float value = 1; +} + +// Wrapper message for `int64`. +// +// The JSON representation for `Int64Value` is JSON string. +message Int64Value { + // The int64 value. + int64 value = 1; +} + +// Wrapper message for `uint64`. +// +// The JSON representation for `UInt64Value` is JSON string. +message UInt64Value { + // The uint64 value. + uint64 value = 1; +} + +// Wrapper message for `int32`. +// +// The JSON representation for `Int32Value` is JSON number. +message Int32Value { + // The int32 value. + int32 value = 1; +} + +// Wrapper message for `uint32`. +// +// The JSON representation for `UInt32Value` is JSON number. +message UInt32Value { + // The uint32 value. + uint32 value = 1; +} + +// Wrapper message for `bool`. +// +// The JSON representation for `BoolValue` is JSON `true` and `false`. +message BoolValue { + // The bool value. + bool value = 1; +} + +// Wrapper message for `string`. +// +// The JSON representation for `StringValue` is JSON string. +message StringValue { + // The string value. + string value = 1; +} + +// Wrapper message for `bytes`. +// +// The JSON representation for `BytesValue` is JSON string. +message BytesValue { + // The bytes value. + bytes value = 1; +} diff --git a/common/core/src/commonMain/resources/timemates/rsp/ack.proto b/common/core/src/commonMain/resources/timemates/rsp/ack.proto new file mode 100644 index 0000000..6b5e4d5 --- /dev/null +++ b/common/core/src/commonMain/resources/timemates/rsp/ack.proto @@ -0,0 +1,41 @@ +syntax = "proto3"; + +package timemates.rrpc; + +// The Ack message type is used for fire-and-forget requests and metadata push in RPCs. +// +// In the fire-and-forget pattern, the client sends a request and does not expect any response data. +// The server processes the request but does not return any meaningful data. The `Ack` message serves +// as a placeholder to indicate that the request should use the fire-and-forget request type as defined +// by the RSocket specification. +// +// For metadata push, the `Ack` message type is used in scenarios where the client sends metadata to the +// server, and the server acknowledges the reception without requiring specific response data. The `Ack` +// message is used to confirm the receipt of metadata from the client. +// +// Note: Changing the return type of an RPC to or from `Ack` is considered a binary-incompatible change. +// This affects client-server communication compatibility, and such changes should be carefully managed +// to avoid breaking existing clients. +// +// Example 1: Fire-and-Forget RPC +// service MyService { +// rpc foo(Bar) returns (Ack) { +// option (timemates.rrpc.enforcedRequestType) = FIRE_AND_FORGET; +// } +// } +// +// message Bar { +// string data = 1; +// } +// +// Example 2: Metadata Push +// service MetadataService { +// rpc pushMetadata(Ack) returns (Ack) { +// option (timemates.rrpc.enforcedRequestType) = METADATA_PUSH; +// } +// } +// +// For cases when Ack is specified in the RPC request type, but not in the returning type – it's +// treated as google.protobuf.Empty, with the difference that actual underlying type is ByteReadPacket.Empty for Kotlin. +// +message Ack {} diff --git a/common/core/src/jsMain/kotlin/com/google/protobuf/TimestampExt.kt b/common/core/src/jsMain/kotlin/com/google/protobuf/TimestampExt.kt new file mode 100644 index 0000000..867b82d --- /dev/null +++ b/common/core/src/jsMain/kotlin/com/google/protobuf/TimestampExt.kt @@ -0,0 +1,7 @@ +package com.google.protobuf + +import kotlin.js.Date + +public fun ProtoTimestamp.toJsDate(): Date = Date( + seconds * 1000, +) \ No newline at end of file diff --git a/common/core/src/jvmMain/kotlin/com/google/protobuf/TimestampExt.kt b/common/core/src/jvmMain/kotlin/com/google/protobuf/TimestampExt.kt new file mode 100644 index 0000000..578c5c5 --- /dev/null +++ b/common/core/src/jvmMain/kotlin/com/google/protobuf/TimestampExt.kt @@ -0,0 +1,15 @@ +package com.google.protobuf + +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneOffset + +public fun ProtoTimestamp.toJavaInstant(): Instant = + Instant.ofEpochSecond(seconds, nanos.toLong()) + +public fun ProtoTimestamp.toJavaLocalDateTime(): LocalDateTime = + LocalDateTime.ofInstant(toJavaInstant(), ZoneOffset.UTC) + +public fun ProtoTimestamp.toJavaLocalDate(): LocalDate = + LocalDate.ofInstant(toJavaInstant(), ZoneOffset.UTC) \ No newline at end of file diff --git a/common/core/src/jvmTest/kotlin/org/timemates/rsp/common/test/InterceptorsTest.kt b/common/core/src/jvmTest/kotlin/org/timemates/rsp/common/test/InterceptorsTest.kt new file mode 100644 index 0000000..a80a313 --- /dev/null +++ b/common/core/src/jvmTest/kotlin/org/timemates/rsp/common/test/InterceptorsTest.kt @@ -0,0 +1,88 @@ +@file:OptIn(ExperimentalInterceptorsApi::class, InternalRRpcAPI::class) + +package org.timemates.rrpc.common.test + +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.timemates.rrpc.Single +import org.timemates.rrpc.annotations.ExperimentalInterceptorsApi +import org.timemates.rrpc.annotations.InternalRRpcAPI +import org.timemates.rrpc.instances.InstanceContainer +import org.timemates.rrpc.interceptors.Interceptor +import org.timemates.rrpc.interceptors.InterceptorContext +import org.timemates.rrpc.interceptors.Interceptors +import org.timemates.rrpc.metadata.ClientMetadata +import org.timemates.rrpc.metadata.ServerMetadata +import org.timemates.rrpc.options.OptionsWithValue +import kotlin.test.Test +import kotlin.test.assertNull +import kotlin.test.assertSame + +class InterceptorsTest { + @Test + fun `runInputInterceptors with no interceptors should return null`(): Unit = runBlocking { + val interceptors = Interceptors(emptyList(), emptyList()) + + assertNull( + actual = interceptors.runInputInterceptors( + Single(""), + ClientMetadata(), + OptionsWithValue.EMPTY, + InstanceContainer(emptyMap()), + ) + ) + } + + @Test + fun `runInputInterceptors with interceptors that change context should return actual context`(): Unit = runBlocking { + val testInterceptor = mockk>() + val expectedContext = mockk>() + coEvery { testInterceptor.intercept(any()) } returns expectedContext + + val interceptors = Interceptors(listOf(testInterceptor), emptyList()) + + assertSame( + actual = interceptors.runInputInterceptors( + Single(""), + ClientMetadata(), + OptionsWithValue.EMPTY, + InstanceContainer(emptyMap()), + ), + expected = expectedContext, + ) + } + + @Test + fun `runOutputInterceptors with no interceptors should return the same context early`(): Unit = runBlocking { + val interceptors = Interceptors(emptyList(), emptyList()) + + assertNull( + actual = interceptors.runOutputInterceptors( + Single(""), + ServerMetadata(), + OptionsWithValue.EMPTY, + InstanceContainer(emptyMap()), + ) + ) + } + + @Test + fun `runOutputInterceptors with interceptors that change context should return actual context`(): Unit = runBlocking { + val testInterceptor = mockk>() + val expectedContext = mockk>() + coEvery { testInterceptor.intercept(any()) } returns expectedContext + + val interceptors = Interceptors(emptyList(), listOf(testInterceptor)) + + assertSame( + actual = interceptors.runOutputInterceptors( + Single(""), + ServerMetadata(), + OptionsWithValue.EMPTY, + InstanceContainer(emptyMap()), + ), + expected = expectedContext, + ) + } +} \ No newline at end of file diff --git a/common/schema/build.gradle.kts b/common/schema/build.gradle.kts new file mode 100644 index 0000000..00c75bd --- /dev/null +++ b/common/schema/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + id(libs.plugins.conventions.multiplatform.library.get().pluginId) + alias(libs.plugins.kotlinx.serialization) +} + +dependencies { + // -- Project -- + commonMainImplementation(projects.common.core) + + // -- Serialization -- + commonMainImplementation(libs.kotlinx.serialization.proto) +} + + +kotlin { +// js(IR) { +// browser() +// nodejs() +// } +// iosArm64() +// iosX64() +// iosSimulatorArm64() +} + +mavenPublishing { + coordinates( + groupId = "org.timemates.rrpc", + artifactId = "common-schema", + version = System.getenv("LIB_VERSION") ?: return@mavenPublishing, + ) + + pom { + name.set("RRpc Common Metadata") + description.set("Multiplatform Kotlin Metadata library for RRpc servers and clients.") + } +} \ No newline at end of file diff --git a/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/Documentable.kt b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/Documentable.kt new file mode 100644 index 0000000..f18b615 --- /dev/null +++ b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/Documentable.kt @@ -0,0 +1,5 @@ +package org.timemates.rrpc.common.schema + +public interface Documentable { + public val documentation: String? +} \ No newline at end of file diff --git a/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/Language.kt b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/Language.kt new file mode 100644 index 0000000..ad64a1c --- /dev/null +++ b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/Language.kt @@ -0,0 +1,8 @@ +package org.timemates.rrpc.common.schema + +import kotlinx.serialization.Serializable + +@Serializable +public enum class Language { + JAVA, KOTLIN, PHP, C_SHARP, PYTHON, +} \ No newline at end of file diff --git a/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSEnumConstant.kt b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSEnumConstant.kt new file mode 100644 index 0000000..fd13bf0 --- /dev/null +++ b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSEnumConstant.kt @@ -0,0 +1,16 @@ +package org.timemates.rrpc.common.schema + +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +@Serializable +public data class RSEnumConstant( + @ProtoNumber(1) + val name: String, + @ProtoNumber(2) + val tag: Int, + @ProtoNumber(3) + public val options: RSOptions, + @ProtoNumber(4) + override val documentation: String?, +) : RSNode, Documentable \ No newline at end of file diff --git a/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSExtend.kt b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSExtend.kt new file mode 100644 index 0000000..43c9aed --- /dev/null +++ b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSExtend.kt @@ -0,0 +1,17 @@ +package org.timemates.rrpc.common.schema + +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber +import org.timemates.rrpc.common.schema.value.RMDeclarationUrl + +@Serializable +public data class RSExtend( + @ProtoNumber(1) + public val typeUrl: RMDeclarationUrl, + @ProtoNumber(2) + public val name: String, + @ProtoNumber(3) + public val fields: List, + @ProtoNumber(4) + override val documentation: String?, +) : Documentable, RSNode \ No newline at end of file diff --git a/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSField.kt b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSField.kt new file mode 100644 index 0000000..7c43914 --- /dev/null +++ b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSField.kt @@ -0,0 +1,26 @@ +package org.timemates.rrpc.common.schema + +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber +import org.timemates.rrpc.common.schema.value.RMDeclarationUrl + +@Serializable +public data class RSField( + @ProtoNumber(1) + public val tag: Int, + @ProtoNumber(2) + public val name: String, + @ProtoNumber(3) + public val options: RSOptions, + @ProtoNumber(4) + override val documentation: String?, + @ProtoNumber(5) + public val typeUrl: RMDeclarationUrl, + @ProtoNumber(6) + public val isRepeated: Boolean, + @ProtoNumber(7) + public val isInOneOf: Boolean = false, + @ProtoNumber(8) + public val isExtension: Boolean = false, +) : Documentable, RSNode + diff --git a/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSFile.kt b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSFile.kt new file mode 100644 index 0000000..8217900 --- /dev/null +++ b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSFile.kt @@ -0,0 +1,81 @@ +package org.timemates.rrpc.common.schema + +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber +import org.timemates.rrpc.common.schema.annotations.NonPlatformSpecificAccess +import org.timemates.rrpc.common.schema.value.RMPackageName + +@Serializable +public class RSFile( + /** + * Name of a source file. + */ + @ProtoNumber(1) + public val name: String, + + /** + * Package name specified in the proto file. It should not be accessed to generate + * platform-specific code, but through `platformPackageName(language)`. + */ + @ProtoNumber(2) + @NonPlatformSpecificAccess + public val packageName: RMPackageName, + + /** + * File-level options of the file. + */ + @ProtoNumber(3) + public val options: RSOptions, + + /** + * The services that are defined in the `.proto` file. + */ + @ProtoNumber(4) + public val services: List, + + /** + * Types that are defined in `.proto` file. + */ + @ProtoNumber(5) + public val types: List, + + /** + * Declared extends in file. + */ + @ProtoNumber(6) + public val extends: List, +) : RSNode { + public companion object { + public val JAVA_PACKAGE: RSTypeMemberUrl = RSTypeMemberUrl(RSOptions.FILE_OPTIONS, "java_package") + } + + /** + * Gets platform-specific package name based on the [language] that is provided by + * accessing file-level options. + */ + @OptIn(NonPlatformSpecificAccess::class) + public fun platformPackageName(language: Language): RMPackageName { + return when (language) { + Language.JAVA, Language.KOTLIN -> (options[JAVA_PACKAGE]?.value as? RSOption.Value.Raw)?.string + Language.PHP -> TODO() + Language.C_SHARP -> TODO() + Language.PYTHON -> TODO() + }?.let(::RMPackageName) ?: packageName + } + + public val allTypes: List by lazy { + val result = mutableListOf() + var typesToProcess = types + + while (typesToProcess.isNotEmpty()) { + val nextTypes = typesToProcess.flatMap { it.nestedTypes } + result.addAll(typesToProcess) // Add the current level of types + typesToProcess = nextTypes + } + + result + } +} + +public fun RSFile.javaPackage(): RMPackageName = platformPackageName(Language.JAVA) +public fun RSFile.kotlinPackage(): RMPackageName = platformPackageName(Language.KOTLIN) diff --git a/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSNode.kt b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSNode.kt new file mode 100644 index 0000000..5f13f06 --- /dev/null +++ b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSNode.kt @@ -0,0 +1,3 @@ +package org.timemates.rrpc.common.schema + +public sealed interface RSNode \ No newline at end of file diff --git a/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSOneOf.kt b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSOneOf.kt new file mode 100644 index 0000000..9332751 --- /dev/null +++ b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSOneOf.kt @@ -0,0 +1,16 @@ +package org.timemates.rrpc.common.schema + +import kotlinx.serialization.SerialName +import kotlinx.serialization.protobuf.ProtoNumber + +@SerialName("ONE_OF") +public data class RSOneOf( + @ProtoNumber(1) + public val name: String, + @ProtoNumber(2) + public val fields: List, + @ProtoNumber(3) + val documentation: String?, + @ProtoNumber(4) + val options: RSOptions, +) \ No newline at end of file diff --git a/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSOption.kt b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSOption.kt new file mode 100644 index 0000000..0974437 --- /dev/null +++ b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSOption.kt @@ -0,0 +1,35 @@ +package org.timemates.rrpc.common.schema + +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber +import kotlinx.serialization.protobuf.ProtoOneOf +import kotlin.jvm.JvmInline + + +@Serializable +public class RSOption( + @ProtoNumber(1) + public val name: String, + @ProtoNumber(2) + public val fieldUrl: RSTypeMemberUrl, + /** + * Value is present only if RPSOption is present in position where a specific value is + * possible, it can also be default value if supported. + */ + @ProtoOneOf + public val value: Value?, +) { + public companion object { + public val DEPRECATED: RSTypeMemberUrl = RSTypeMemberUrl(RSOptions.METHOD_OPTIONS, "deprecated") + } + + @Serializable + public sealed interface Value { + @JvmInline + public value class Raw(@ProtoNumber(3) public val string: String) : Value + @JvmInline + public value class RawMap(@ProtoNumber(4) public val map: Map) : Value + @JvmInline + public value class MessageMap(@ProtoNumber(5) public val map: Map) : Value + } +} \ No newline at end of file diff --git a/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSOptions.kt b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSOptions.kt new file mode 100644 index 0000000..13160b7 --- /dev/null +++ b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSOptions.kt @@ -0,0 +1,36 @@ +package org.timemates.rrpc.common.schema + +import kotlinx.serialization.Serializable +import org.timemates.rrpc.common.schema.value.RMDeclarationUrl +import kotlin.jvm.JvmInline + + +@Serializable +@JvmInline +public value class RSOptions( + public val list: List +) { + public companion object { + public val EMPTY: RSOptions = RSOptions(emptyList()) + + public val FILE_OPTIONS: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.FileOptions") + public val MESSAGE_OPTIONS: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.MessageOptions") + public val SERVICE_OPTIONS: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.ServiceOptions") + public val FIELD_OPTIONS: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.FieldOptions") + public val ONEOF_OPTIONS: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.OneofOptions") + public val ENUM_OPTIONS: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.EnumOptions") + public val ENUM_VALUE_OPTIONS: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.EnumValueOptions") + public val METHOD_OPTIONS: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.MethodOptions") + public val EXTENSION_RANGE_OPTIONS: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.ExtensionRangeOptions") + } + + public operator fun get(fieldUrl: RSTypeMemberUrl): RSOption? { + return list.firstOrNull { it.fieldUrl == fieldUrl } + } + + public operator fun contains(fieldUrl: RSTypeMemberUrl): Boolean { + return get(fieldUrl) != null + } +} + +public val RSOptions.isDeprecated: Boolean get() = (this[RSOption.DEPRECATED]?.value as? RSOption.Value.Raw)?.string?.toBooleanStrictOrNull() ?: false \ No newline at end of file diff --git a/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSResolver.kt b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSResolver.kt new file mode 100644 index 0000000..18dbe36 --- /dev/null +++ b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSResolver.kt @@ -0,0 +1,132 @@ +package org.timemates.rrpc.common.schema + +import org.timemates.rrpc.common.schema.value.RMDeclarationUrl + +public fun RSResolver( + files: List, +): RSResolver = InMemoryRSResolver(files) + +public fun RSResolver( + vararg resolvers: RSResolver, +): RSResolver = InMemoryRSResolver(resolvers.flatMap { it.resolveAvailableFiles() }) + +/** + * Interface for resolving various components (fields, types, files, services) in the RPC metadata model. + * This provides a lookup mechanism for retrieving metadata elements such as types, services, fields, + * extensions, and files based on unique identifiers like type URLs or package names. + */ +public interface RSResolver { + + /** + * Resolves a field within a type by the given [typeMemberUrl]. + * + * @param typeMemberUrl The URL that identifies the specific field within the type. + * @return The corresponding [RSField] if found, or `null` if no matching field is found. + */ + public fun resolveField(typeMemberUrl: RSTypeMemberUrl): RSField? + + /** + * Resolves a type by the given [url]. + * + * @param url The unique identifier for the type, typically used in protobuf definitions. + * @return The corresponding [RSType] if found, or `null` if no matching type is found. + */ + public fun resolveType(url: RMDeclarationUrl): RSType? + + /** + * Resolves the file where a type is present. + * + * @param url The reference to the type. + * @return The corresponding [RSFile] where the type is found, or `null` if no matching file is found. + */ + public fun resolveFileOf(url: RMDeclarationUrl): RSFile? + + /** + * Resolves a service by the given [url]. + * + * @param url The unique identifier for the service, typically used in protobuf definitions. + * @return The corresponding [RSService] if found, or `null` if no matching service is found. + */ + public fun resolveService(url: RMDeclarationUrl): RSService? + + /** + * Resolves all available files in the current [RSResolver]. + * + * @return A sequence of all [RSFile]s available within this resolver. + */ + public fun resolveAvailableFiles(): Sequence + + /** + * Resolves all available services in the current [RSResolver]. + * + * @return A sequence of all [RSService]s available within this resolver. + */ + public fun resolveAllServices(): Sequence + + /** + * Resolves all available types in the current [RSResolver]. + * + * @return A sequence of all [RSType]s available within this resolver. + */ + public fun resolveAllTypes(): Sequence +} + +private class InMemoryRSResolver( + private val files: List, +) : RSResolver { + private val servicesIndex: Map by lazy { + files.flatMap { it.services }.associateBy { it.typeUrl } + } + private val typesIndex: Map by lazy { + files.flatMap { it.allTypes }.associateBy { it.typeUrl } + } + + private val filesIndex: Map by lazy { + buildMap { + files.forEach { file -> + file.allTypes.forEach { type -> + put(type.typeUrl, file) + } + } + } + } + private val fieldsIndex: Map by lazy { + buildMap { + typesIndex.values + .asSequence() + .filterIsInstance() + .flatMap { it.fields } + .forEach { field -> + put(RSTypeMemberUrl(field.typeUrl, field.name), field) + } + } + } + + override fun resolveField(typeMemberUrl: RSTypeMemberUrl): RSField? { + return fieldsIndex[typeMemberUrl] + } + + override fun resolveType(url: RMDeclarationUrl): RSType? { + return typesIndex[url] + } + + override fun resolveFileOf(url: RMDeclarationUrl): RSFile? { + return filesIndex[url] + } + + override fun resolveService(url: RMDeclarationUrl): RSService? { + return servicesIndex[url] + } + + override fun resolveAvailableFiles(): Sequence { + return files.asSequence() + } + + override fun resolveAllServices(): Sequence { + return servicesIndex.values.asSequence() + } + + override fun resolveAllTypes(): Sequence { + return typesIndex.values.asSequence() + } +} diff --git a/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSRpc.kt b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSRpc.kt new file mode 100644 index 0000000..729db10 --- /dev/null +++ b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSRpc.kt @@ -0,0 +1,67 @@ +package org.timemates.rrpc.common.schema + +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber +import org.timemates.rrpc.common.schema.annotations.NonPlatformSpecificAccess +import org.timemates.rrpc.common.schema.value.RMDeclarationUrl + +@Serializable +public class RSRpc( + /** + * The name of RPC. + * + * Marked with [NonPlatformSpecificAccess] because code-generation should + * adapt the name to the language's naming convention. For example, for Java + * and Kotlin, it should start with a lowercase letter instead of uppercase as in the + * ProtoBuf naming convention. If we generate some kind of metadata to the service, it's + * absolutely legal and required to keep name as in original `.proto` definition, otherwise + * it may break cross-language support. + * + * @see languageSpecificName + */ + @ProtoNumber(1) + @NonPlatformSpecificAccess + public val name: String, + + /** + * Denotes the input of RPC that comes from client to server. The type + * might be a stream, make sure you made a check. + */ + @ProtoNumber(2) + public val requestType: StreamableRMTypeUrl, + + /** + * Denotes the out of RPC that comes from server to client. The type + * might be a stream, make sure you made a check. + */ + @ProtoNumber(3) + public val responseType: StreamableRMTypeUrl, + + /** + * The options that are specified for given RPC. + */ + @ProtoNumber(4) + public val options: RSOptions, + @ProtoNumber(5) + override val documentation: String?, +) : RSNode, Documentable { + public fun languageSpecificName(language: Language): String { + @OptIn(NonPlatformSpecificAccess::class) + return when (language) { + Language.JAVA, Language.KOTLIN, Language.PHP, Language.PYTHON -> + name.replaceFirstChar { it.lowercase() } + else -> TODO() + } + } +} + +public fun RSRpc.javaName(): String = languageSpecificName(Language.JAVA) +public fun RSRpc.kotlinName(): String = languageSpecificName(Language.KOTLIN) + +public val RSRpc.isRequestResponse: Boolean get() = !requestType.isStreaming && !responseType.isStreaming +public val RSRpc.isRequestStream: Boolean get() = !requestType.isStreaming && responseType.isStreaming +public val RSRpc.isRequestChannel: Boolean get() = requestType.isStreaming && responseType.isStreaming +public val RSRpc.isFireAndForget: Boolean + get() = requestType.type != RMDeclarationUrl.ACK && responseType.type == RMDeclarationUrl.ACK +public val RSRpc.isMetadataPush: Boolean + get() = requestType.type == RMDeclarationUrl.ACK && responseType.type == RMDeclarationUrl.ACK \ No newline at end of file diff --git a/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSService.kt b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSService.kt new file mode 100644 index 0000000..7a443f9 --- /dev/null +++ b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSService.kt @@ -0,0 +1,33 @@ +package org.timemates.rrpc.common.schema + +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber +import org.timemates.rrpc.common.schema.value.RMDeclarationUrl + + +@Serializable +public class RSService( + /** + * Name of the service. + */ + @ProtoNumber(1) + public val name: String, + + /** + * List of RPCs (Remote Procedure Calls) defined in this service. + */ + @ProtoNumber(2) + public val rpcs: List, + + /** + * Options on service-level. + */ + @ProtoNumber(3) + public val options: RSOptions, + + /** + * String reference representation. + */ + @ProtoNumber(4) + public val typeUrl: RMDeclarationUrl, +) : RSNode diff --git a/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSType.kt b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSType.kt new file mode 100644 index 0000000..69bfe27 --- /dev/null +++ b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSType.kt @@ -0,0 +1,108 @@ +package org.timemates.rrpc.common.schema + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber +import org.timemates.rrpc.common.schema.value.RMDeclarationUrl + +@Serializable +public sealed interface RSType : RSNode, Documentable { + @ProtoNumber(1) + public val name: String + @ProtoNumber(2) + public val typeUrl: RMDeclarationUrl + @ProtoNumber(3) + public val nestedTypes: List + @ProtoNumber(4) + public val nestedExtends: List + @ProtoNumber(5) + public override val documentation: String? + @ProtoNumber(6) + public val options: RSOptions + + @SerialName("ENUM") + public class Enum( + @ProtoNumber(1) + override val name: String, + @ProtoNumber(7) + public val constants: List, + @ProtoNumber(5) + override val documentation: String?, + @ProtoNumber(6) + public override val options: RSOptions, + @ProtoNumber(3) + override val nestedTypes: List, + @ProtoNumber(2) + override val typeUrl: RMDeclarationUrl, + @ProtoNumber(4) + override val nestedExtends: List, + ) : RSType + + @SerialName("MESSAGE") + public class Message( + @ProtoNumber(1) + override val name: String, + @ProtoNumber(5) + override val documentation: String?, + @ProtoNumber(8) + public val fields: List, + @ProtoNumber(9) + public val oneOfs: List, + @ProtoNumber(6) + public override val options: RSOptions, + @ProtoNumber(2) + override val typeUrl: RMDeclarationUrl, + @ProtoNumber(3) + override val nestedTypes: List, + @ProtoNumber(4) + override val nestedExtends: List, + ) : RSType { + public val allFields: List get() = fields + oneOfs.flatMap { it.fields } + + /** + * Gets [RSField] in current [RSType.Message] by given [tag]. + * If a field with a tag persists in the oneof field – oneof field is returned, + * where the field occurs. + */ + public fun field(tag: Int): RSField? { + return fields.firstOrNull { field -> + field.tag == tag + } + } + + /** + * Gets [RSField] in current [RSType.Message] by given [name]. + * If a field with a tag persists in the oneof field – oneof field is returned, + * where the field occurs. + */ + public fun field(name: String): RSField? { + return fields.firstOrNull { field -> + field.name == name + } + } + + public override fun equals(other: Any?): Boolean { + return other is Message && other.typeUrl == typeUrl + } + + override fun hashCode(): Int { + return typeUrl.hashCode() + } + } + + @SerialName("ENCLOSING_TYPE") + public class Enclosing( + @ProtoNumber(1) + override val name: String, + @ProtoNumber(5) + override val documentation: String?, + @ProtoNumber(2) + override val typeUrl: RMDeclarationUrl, + @ProtoNumber(3) + override val nestedTypes: List, + @ProtoNumber(4) + override val nestedExtends: List, + @ProtoNumber(6) + override val options: RSOptions = RSOptions.EMPTY + ) : RSType +} \ No newline at end of file diff --git a/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSTypeMemberUrl.kt b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSTypeMemberUrl.kt new file mode 100644 index 0000000..565b18d --- /dev/null +++ b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/RSTypeMemberUrl.kt @@ -0,0 +1,17 @@ +package org.timemates.rrpc.common.schema + +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber +import org.timemates.rrpc.common.schema.value.RMDeclarationUrl + +@Serializable +public data class RSTypeMemberUrl( + @ProtoNumber(1) + public val typeUrl: RMDeclarationUrl, + @ProtoNumber(2) + public val memberName: String, +) { + override fun toString(): String { + return "$typeUrl#$memberName" + } +} \ No newline at end of file diff --git a/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/StreamableRMTypeUrl.kt b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/StreamableRMTypeUrl.kt new file mode 100644 index 0000000..a6dda16 --- /dev/null +++ b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/StreamableRMTypeUrl.kt @@ -0,0 +1,16 @@ +package org.timemates.rrpc.common.schema + +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber +import org.timemates.rrpc.common.schema.value.RMDeclarationUrl + +/** + * Denotes that type might be of streaming type. + */ +@Serializable +public class StreamableRMTypeUrl( + @ProtoNumber(1) + public val isStreaming: Boolean, + @ProtoNumber(2) + public val type: RMDeclarationUrl, +) \ No newline at end of file diff --git a/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/annotations/NonPlatformSpecificAccess.kt b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/annotations/NonPlatformSpecificAccess.kt new file mode 100644 index 0000000..9ac8a1f --- /dev/null +++ b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/annotations/NonPlatformSpecificAccess.kt @@ -0,0 +1,16 @@ +package org.timemates.rrpc.common.schema.annotations + +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FIELD, + AnnotationTarget.PROPERTY, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.FUNCTION, +) +@RequiresOptIn( + message = "This declaration requires careful usage regarding different platforms.", + level = RequiresOptIn.Level.WARNING, +) +public annotation class NonPlatformSpecificAccess \ No newline at end of file diff --git a/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/value/RMDeclarationUrl.kt b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/value/RMDeclarationUrl.kt new file mode 100644 index 0000000..6711922 --- /dev/null +++ b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/value/RMDeclarationUrl.kt @@ -0,0 +1,128 @@ +package org.timemates.rrpc.common.schema.value + +import kotlinx.serialization.Serializable +import kotlin.jvm.JvmInline + +@Serializable +@JvmInline +public value class RMDeclarationUrl(public val value: String) { + public val isScalar: Boolean get() = this in SCALAR_TYPES + public val isWrapper: Boolean get() = this in WRAPPER_TYPES + public val isGoogleBuiltin: Boolean get() = this in GOOGLE_BUILTIN_TYPES + + public val isMap: Boolean get() = value.startsWith("map<") + public val firstTypeArgument: RMDeclarationUrl? get() = + if (isMap) + RMDeclarationUrl(value.substringAfter('<').substringBefore(',')) + else null + + public val secondTypeArgument: RMDeclarationUrl? get() = + if (isMap) + RMDeclarationUrl(value.substringAfter(',').substringBefore('>').trim()) + else null + + public val simpleName: String get() = value.substringAfterLast('.') + public val enclosingTypeOrPackage: String? get() { + val string = value.substringAfterLast('/') + val dot = string.lastIndexOf('.') + return if (dot == -1) null else string.substring(0, dot) + } + + override fun toString(): String { + return value + } + + public companion object { + public val UNKNOWN: RMDeclarationUrl = RMDeclarationUrl("unknown") + + public val INT32: RMDeclarationUrl = RMDeclarationUrl("int32") + public val INT64: RMDeclarationUrl = RMDeclarationUrl("int32") + + public val STRING: RMDeclarationUrl = RMDeclarationUrl("string") + + public val SINT32: RMDeclarationUrl = RMDeclarationUrl("int32") + public val SINT64: RMDeclarationUrl = RMDeclarationUrl("int32") + + public val BOOL: RMDeclarationUrl = RMDeclarationUrl("bool") + + public val UINT32: RMDeclarationUrl = RMDeclarationUrl("uint32") + public val UINT64: RMDeclarationUrl = RMDeclarationUrl("uint64") + + public val SFIXED32: RMDeclarationUrl = RMDeclarationUrl("sfixed32") + public val SFIXED64: RMDeclarationUrl = RMDeclarationUrl("sfixed64") + + public val FIXED32: RMDeclarationUrl = RMDeclarationUrl("fixed32") + public val FIXED64: RMDeclarationUrl = RMDeclarationUrl("fixed64") + + public val FLOAT: RMDeclarationUrl = RMDeclarationUrl("float") + public val DOUBLE: RMDeclarationUrl = RMDeclarationUrl("double") + + public val BYTES: RMDeclarationUrl = RMDeclarationUrl("bytes") + + public fun ofMap(first: RMDeclarationUrl, second: RMDeclarationUrl): RMDeclarationUrl { + return RMDeclarationUrl("map<${first.value}, ${second.value}>") + } + + public val SCALAR_TYPES: List = listOf( + INT32, + INT64, + STRING, + SINT32, + SINT64, + BOOL, + UINT32, + UINT64, + STRING, + FIXED32, + FIXED64, + FLOAT, + DOUBLE, + BYTES, + ) + + public val ANY: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.Any") + public val TIMESTAMP: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.Timestamp") + public val DURATION: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.Duration") + public val EMPTY: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.Empty") + public val STRUCT: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.Struct") + public val STRUCT_MAP: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.StructMap") + public val STRUCT_VALUE: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.Value") + public val STRUCT_NULL: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.NullValue") + public val STRUCT_LIST: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.ListValue") + + public val GOOGLE_BUILTIN_TYPES: List = listOf( + ANY, + TIMESTAMP, + DURATION, + EMPTY, + STRUCT, + STRUCT_MAP, + STRUCT_VALUE, + STRUCT_NULL, + STRUCT_LIST, + ) + + public val DOUBLE_VALUE: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.DoubleValue") + public val FLOAT_VALUE: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.FloatValue") + public val INT32_VALUE: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.Int32Value") + public val INT64_VALUE: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.Int64Value") + public val UINT32_VALUE: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.UInt32Value") + public val UINT64_VALUE: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.UINT64Value") + public val STRING_VALUE: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.StringValue") + public val BYTES_VALUE: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.BytesValue") + public val BOOL_VALUE: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/google.protobuf.BoolValue") + + public val ACK: RMDeclarationUrl = RMDeclarationUrl("type.googleapis.com/timemates.rrpc.Ack") + + public val WRAPPER_TYPES: List = listOf( + DOUBLE_VALUE, + FLOAT_VALUE, + INT32_VALUE, + INT64_VALUE, + UINT32_VALUE, + UINT64_VALUE, + STRING_VALUE, + BYTES_VALUE, + ) + } +} \ No newline at end of file diff --git a/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/value/RMPackageName.kt b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/value/RMPackageName.kt new file mode 100644 index 0000000..a6ad4e2 --- /dev/null +++ b/common/schema/src/commonMain/kotlin/org/timemates/rrpc/common/schema/value/RMPackageName.kt @@ -0,0 +1,10 @@ +package org.timemates.rrpc.common.schema.value + +import kotlinx.serialization.Serializable +import kotlin.jvm.JvmInline + +@Serializable +@JvmInline +public value class RMPackageName( + public val value: String, +) \ No newline at end of file diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts deleted file mode 100644 index 4cb9368..0000000 --- a/gradle-plugin/build.gradle.kts +++ /dev/null @@ -1,37 +0,0 @@ -plugins { - `kotlin-dsl` - alias(libs.plugins.gradle.publish) -} - -group = "org.timemates.rsproto" -version = System.getenv("LIB_VERSION") ?: "SNAPSHOT" - -kotlin { - explicitApi() -} - -dependencies { - constraints { - api("org.timemates.rsproto:code-generator:$version") - } - api(projects.codeGenerator) - - implementation(libs.kotlin.plugin) - implementation(libs.squareup.okio) -} - -gradlePlugin { - website = "https://github.com/timemates/rsproto" - vcsUrl = "https://github.com/timemates/rsproto" - - plugins { - create("rsproto-plugin") { - id = "org.timemates.rsproto" - displayName = "RSProto Code Generator" - description = "Code Generator from .proto files to Kotlin code." - tags = listOf("kotlin", "rsocket", "protobuf", "proto") - - implementationClass = "org.timemates.rsproto.plugin.RSocketProtoGeneratorPlugin" - } - } -} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/org/timemates/rsproto/plugin/RSProtoExtension.kt b/gradle-plugin/src/main/kotlin/org/timemates/rsproto/plugin/RSProtoExtension.kt deleted file mode 100644 index 0fe7267..0000000 --- a/gradle-plugin/src/main/kotlin/org/timemates/rsproto/plugin/RSProtoExtension.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.timemates.rsproto.plugin - -import org.gradle.api.model.ObjectFactory -import org.gradle.api.provider.Property -import org.gradle.api.provider.Provider -import org.gradle.kotlin.dsl.property - -/** - * Class representing the extension for generating Protobuf code. - */ -public open class RSProtoExtension(objects: ObjectFactory) { - /** - * If you have custom project structure that does not have commonMain / main sourceSets, - * you should specify your main source set in the [targetSourceSet]. - */ - public val targetSourceSet: Property = objects.property() - .convention(null) - - /** - * Contains the path to the folder where the Proto definition files are located. - */ - public val protoSourcePath: Property = - objects.property().convention("src/main/proto") - - /** - * Contains the path to the folder where the generated code will be saved. - */ - public val generationOutputPath: Property = - objects.property().convention("generated/proto-generator/src/commonMain") - - /** - * Represents the flag indicating whether the code generation for the client should be performed. - */ - public val clientGeneration: Property = objects.property().convention(true) - /** - * Represents the flag indicating whether the code generation for the server should be performed. - */ - public val serverGeneration: Property = objects.property().convention(true) -} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/org/timemates/rsproto/plugin/RSocketProtoGeneratorPlugin.kt b/gradle-plugin/src/main/kotlin/org/timemates/rsproto/plugin/RSocketProtoGeneratorPlugin.kt deleted file mode 100644 index 452ce66..0000000 --- a/gradle-plugin/src/main/kotlin/org/timemates/rsproto/plugin/RSocketProtoGeneratorPlugin.kt +++ /dev/null @@ -1,72 +0,0 @@ -package org.timemates.rsproto.plugin - -import okio.FileSystem -import okio.Path.Companion.toOkioPath -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.kotlin.dsl.create -import org.gradle.kotlin.dsl.findByType -import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension -import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension -import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension -import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet -import org.timemates.rsproto.codegen.CodeGenerator -import java.io.File - -public class RSocketProtoGeneratorPlugin : Plugin { - override fun apply(target: Project) { - val extension = target.extensions.create("rsproto", target.objects) - val generationOutputPath = target.layout.buildDirectory.file(extension.generationOutputPath) - - val generateProto = target.tasks.create("generateProto") { - group = "rsproto" - - inputs.dir(extension.protoSourcePath) - outputs.dir(generationOutputPath) - - doLast { - val codeGenerator = CodeGenerator(FileSystem.SYSTEM) - - try { - generationOutputPath.get() - .asFile - .listFiles() - ?.forEach(File::deleteRecursively) - } catch (e: Exception) { - if(logger.isDebugEnabled) - logger.error(e.stackTraceToString()) - } - - codeGenerator.generate( - rootPath = target.file(extension.protoSourcePath.get()).toOkioPath(), - outputPath = target.file(generationOutputPath).toOkioPath(), - clientGeneration = extension.clientGeneration.get(), - serverGeneration = extension.serverGeneration.get(), - ) - } - } - - target.afterEvaluate { - val allSourceSets = target.extensions.findByType()?.sourceSets - ?: target.extensions.findByType()?.sourceSets - ?: target.extensions.findByType()?.sourceSets - ?: error("Does Kotlin plugin apply to the buildscript?") - - val commonSourceSet = allSourceSets - .findByName(KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME) - val mainSourceSet = allSourceSets.findByName("main") - - val sourceSet = if (extension.targetSourceSet.getOrNull() != null) - allSourceSets.getByName(extension.targetSourceSet.get()) - else commonSourceSet ?: mainSourceSet - - sourceSet - ?.kotlin - ?.srcDirs(generateProto.outputs) - ?: error(SOURCE_SET_NOT_FOUND) - } - } -} - -private const val SOURCE_SET_NOT_FOUND = - "Unable to obtain source set: you should have commonMain/main or custom one that is set up in the [rsproto.targetSourceSet]" \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 069789f..952454a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,26 +1,32 @@ [versions] -kotlin = "2.0.0" -kotlinx-coroutines = "1.9.0-RC" -kotlinx-serialization = "1.7.0-RC" -ktor = "2.3.11" +kotlin = "2.0.21" +kotlinx-coroutines = "1.9.0" +kotlinx-serialization = "1.7.3" +ktor = "2.3.12" jupiter = "5.4.0" exposed = "0.41.1" android-gradle-plugin = "7.3.1" -okio = "3.6.0" +okio = "3.9.1" rsocket = "0.16.0" +compose = "1.7.0" +decompose = "3.2.1" +flowmvi = "3.1.0-beta02" +clikt = "5.0.1" +graalvm = "0.10.3" [libraries] # kotlinx libraries kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-serialization-proto = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "kotlinx-serialization" } # Ktor libraries ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" } -ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" } ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" } ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } -ktor-server-statusPages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } @@ -28,6 +34,7 @@ ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx # RSocket libraries rsocket-server = { module = "io.rsocket.kotlin:rsocket-ktor-server", version.ref = "rsocket" } rsocket-client = { module = "io.rsocket.kotlin:rsocket-ktor-client", version.ref = "rsocket" } +rsocket-core = { module = "io.rsocket.kotlin:rsocket-core", version.ref = "rsocket" } # Testing Libraries kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -38,12 +45,12 @@ junit-jupiter = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref # Other Multiplatform Libraries cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.require="0.9.0" } -squareup-wire-schema = { module = "com.squareup.wire:wire-schema", version.require = "4.9.1" } +squareup-wire-schema = { module = "com.squareup.wire:wire-schema", version.require = "5.0.0" } squareup-okio = { module = "com.squareup.okio:okio", version.ref = "okio" } squareup-okio-fakeFs = { module = "com.squareup.okio:okio-fakefilesystem", version.ref = "okio" } -squareup-kotlinpoet = { module = "com.squareup:kotlinpoet", version.require = "1.14.2" } +squareup-kotlinpoet = { module = "com.squareup:kotlinpoet", version.require = "2.0.0" } # Other JVM Libraries h2-database = { module = "com.h2database:h2", version.require = "2.2.224" } @@ -56,11 +63,43 @@ kotlin-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version. vanniktech-maven-publish = { module = "com.vanniktech.maven.publish:com.vanniktech.maven.publish.gradle.plugin", version.require = "0.25.3" } android-plugin = { module = "com.android.tools.build:gradle", version.ref = "android-gradle-plugin" } +mockk = { group = "io.mockk", name = "mockk", version.require = "1.13.12" } + +# CLI +clikt-core = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" } + +## Decompose +decompose = { module = "com.arkivanov.decompose:decompose", version.ref = "decompose" } +decompose-jetbrains-compose = { module = "com.arkivanov.decompose:extensions-compose", version.ref = "decompose" } + +## FlowMVI +# Core KMP module +flowmvi-core = { module = "pro.respawn.flowmvi:core", version.ref = "flowmvi" } +# Test DSL +flowmvi-test = { module = "pro.respawn.flowmvi:test", version.ref = "flowmvi" } +# Compose multiplatform +flowmvi-compose = { module = "pro.respawn.flowmvi:compose", version.ref = "flowmvi" } +# Android (common + view-based) +flowmvi-android = { module = "pro.respawn.flowmvi:android", version.ref = "flowmvi" } +# Multiplatform state preservation +flowmvi-savedstate = { module = "pro.respawn.flowmvi:savedstate", version.ref = "flowmvi" } +# Remote debugging client +flowmvi-debugger-client = { module = "pro.respawn.flowmvi:debugger-plugin", version.ref = "flowmvi" } +# Essenty (Decompose) integration +flowmvi-essenty = { module = "pro.respawn.flowmvi:essenty", version.ref = "flowmvi" } +flowmvi-essenty-compose = { module = "pro.respawn.flowmvi:essenty-compose", version.ref = "flowmvi" } + +# JNA +net-java-jna = { module = "net.java.dev.jna:jna", version.require = "5.8.0" } + +# Bonsai (Trees for Compose) +bonsai-core = { module = "cafe.adriel.bonsai:bonsai-core", version.require = "1.2.0" } + [plugins] # Build Conventions -conventions-multiplatform = { id = "multiplatform-convention", version.require = "SNAPSHOT" } +conventions-multiplatform-core = { id = "multiplatform-convention", version.require = "SNAPSHOT" } conventions-multiplatform-library = { id = "multiplatform-library-convention", version.require = "SNAPSHOT" } -conventions-jvm = { id = "jvm-convention", version.require = "SNAPSHOT" } +conventions-jvm-core = { id = "jvm-convention", version.require = "SNAPSHOT" } conventions-jvm-library = { id = "jvm-library-convention", version.require = "SNAPSHOT" } # Compiler plugins @@ -77,4 +116,11 @@ android-library = { id = "com.android.library", version.ref = "android-gradle-pl android-application = { id = "com.android.library", version.ref = "android-gradle-plugin" } # Gradle -gradle-publish = { id = "com.gradle.plugin-publish", version.require = "1.1.0" } +gradle-publish = { id = "com.gradle.plugin-publish", version.require = "1.2.1" } + +# Jetbrains +jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "compose" } +jetbrains-compiler-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } + +# GraalVM +graalvm-native = { id = "org.graalvm.buildtools.native", version.ref = "graalvm" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e411586..1e2fbf0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/integration-tests/build.gradle.kts b/integration-tests/build.gradle.kts new file mode 100644 index 0000000..100ff8d --- /dev/null +++ b/integration-tests/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + id(libs.plugins.conventions.jvm.core.get().pluginId) + alias(libs.plugins.kotlinx.serialization) +} + +group = "org.timemates.rrpc" +version = System.getenv("LIB_VERSION") ?: "SNAPSHOT" + +dependencies { + // -- Project -- + implementation(projects.server.core) + implementation(projects.server.schema) + implementation(projects.client.core) + implementation(projects.client.schema) + + // -- Serialization -- + implementation(libs.kotlinx.serialization.proto) + + // -- RSocket -- + implementation(libs.rsocket.client) + implementation(libs.rsocket.server) + + // -- Ktor -- + implementation(libs.ktor.server.core) + implementation(libs.ktor.server.netty) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.websockets) + + // -- JUnit -- + implementation(libs.junit.jupiter) + + // -- MockK -- + implementation(libs.mockk) + + // -- Coroutines + implementation(libs.kotlinx.coroutines.test) +} + + diff --git a/integration-tests/src/test/kotlin/org/timemates/rrpc/server/schema/SchemaServiceCommunicationTest.kt b/integration-tests/src/test/kotlin/org/timemates/rrpc/server/schema/SchemaServiceCommunicationTest.kt new file mode 100644 index 0000000..f65e4aa --- /dev/null +++ b/integration-tests/src/test/kotlin/org/timemates/rrpc/server/schema/SchemaServiceCommunicationTest.kt @@ -0,0 +1,38 @@ +package org.timemates.rrpc.server.schema + +//object SchemaServiceCommunicationTest { +// private val testScope = TestScope() +// +// private val port = Random.nextInt(1000, 9999) +// +// private val module = RRpcModule { +// services { +// schemaService() +// } +// } +// +// @JvmStatic +// private val server = embeddedServer(Netty, port = port) { +// routing { +// rrpcEndpoint(module = module) +// } +// }.start(false) +// +// @JvmStatic +// private var schemaClient: SchemaClient by Delegates.notNull() +// +// @JvmStatic +// @BeforeAll +// fun setup(): Unit = runTest { +// val client = HttpClient { +// install(WebSockets) +// install(RSocketSupport) +// } +// val rsocket = client.rSocket(urlString = "localhost:${port}/rrpc") { +// +// } +// schemaClient = SchemaService { +// rsocket(rsocket) +// } +// } +//} \ No newline at end of file diff --git a/internal/dynamic-serialization/build.gradle.kts b/internal/dynamic-serialization/build.gradle.kts new file mode 100644 index 0000000..c1c0d9c --- /dev/null +++ b/internal/dynamic-serialization/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + id(libs.plugins.conventions.multiplatform.library.get().pluginId) +} + +kotlin { + explicitApi() +} + +group = "org.timemates.rrpc" +version = System.getenv("LIB_VERSION") ?: "SNAPSHOT" + +dependencies { + // -- Project -- + commonMainImplementation(projects.common.core) + commonMainImplementation(projects.generator.core) + + // -- Kotlinx Serialization -- + commonMainImplementation(libs.kotlinx.serialization.proto) +} + + +mavenPublishing { + coordinates( + groupId = "org.timemates.rrpc", + artifactId = "dynamic-serialization", + version = System.getenv("LIB_VERSION") ?: return@mavenPublishing, + ) + + pom { + name.set("RRpc Kotlin Dynamic Serialization Generator") + description.set("Code-generation library for generating dynamic KSerializers in Runtime based on common-schema types.") + } +} \ No newline at end of file diff --git a/internal/dynamic-serialization/src/commonMain/kotlin/org/timemates/rrpc/generator/kotlin/dyser/MessageSerializerFactory.kt b/internal/dynamic-serialization/src/commonMain/kotlin/org/timemates/rrpc/generator/kotlin/dyser/MessageSerializerFactory.kt new file mode 100644 index 0000000..5d2a415 --- /dev/null +++ b/internal/dynamic-serialization/src/commonMain/kotlin/org/timemates/rrpc/generator/kotlin/dyser/MessageSerializerFactory.kt @@ -0,0 +1,106 @@ +package org.timemates.rrpc.generator.kotlin.dyser + +import com.google.protobuf.* +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.protobuf.ProtoNumber +import org.timemates.rrpc.common.schema.RSResolver +import org.timemates.rrpc.common.schema.RSType +import org.timemates.rrpc.common.schema.value.RMDeclarationUrl + +public class MessageSerializerFactory( + private val resolver: RSResolver, +) { + private val descriptorsCache: MutableMap = mutableMapOf() + private val serializersCache: MutableMap> = mutableMapOf() + + public fun getMessageSerializer(message: RSType.Message): KSerializer { + return serializersCache.getOrPut(message.typeUrl) { + MessageValuesSerializer(message, this, resolver, getOrCreateMessageDescriptor(message)) + } as MessageValuesSerializer + } + + public fun getDescriptor(type: RSType): SerialDescriptor { + return when (type) { + is RSType.Enclosing -> ProtoEmpty.serializer().descriptor + is RSType.Enum -> getOrCreateEnumDescriptor(type) + is RSType.Message -> getOrCreateMessageDescriptor(type) + } + } + + @OptIn(InternalSerializationApi::class) + private fun getOrCreateEnumDescriptor(enum: RSType.Enum): SerialDescriptor { + return descriptorsCache.getOrPut(enum.typeUrl) { + buildSerialDescriptor(enum.name, SerialKind.ENUM) { + enum.constants.forEach { + element( + elementName = it.name, + descriptor = buildSerialDescriptor(it.name, StructureKind.OBJECT), + annotations = listOf(ProtoNumber(it.tag)), + ) + } + } + } + } + + @OptIn(InternalSerializationApi::class) + private fun getOrCreateMessageDescriptor(message: RSType.Message): SerialDescriptor { + // Check if the descriptor is already cached + descriptorsCache[message.typeUrl]?.let { return it } + + // Create a mutable proxy descriptor and cache it to handle recursion + val proxyDescriptor = SerialDescriptorProxy() + descriptorsCache[message.typeUrl] = proxyDescriptor + + proxyDescriptor.descriptor = buildClassSerialDescriptor(message.name) { + message.allFields.asSequence().sortedBy { it.tag }.map { field -> + field to when (field.typeUrl) { + RMDeclarationUrl.STRING -> String.serializer().descriptor + RMDeclarationUrl.BYTES -> ByteArraySerializer().descriptor + RMDeclarationUrl.INT32 -> Int.serializer().descriptor + RMDeclarationUrl.INT64 -> Long.serializer().descriptor + RMDeclarationUrl.BOOL -> Boolean.serializer().descriptor + RMDeclarationUrl.DOUBLE -> Double.serializer().descriptor + RMDeclarationUrl.FLOAT -> Float.serializer().descriptor + RMDeclarationUrl.UINT32 -> UInt.serializer().descriptor + RMDeclarationUrl.UINT64 -> ULong.serializer().descriptor + RMDeclarationUrl.INT32_VALUE -> ProtoInt32Wrapper.serializer().descriptor + RMDeclarationUrl.INT64_VALUE -> ProtoInt64Wrapper.serializer().descriptor + RMDeclarationUrl.UINT32_VALUE -> ProtoUInt32Wrapper.serializer().descriptor + RMDeclarationUrl.UINT64_VALUE -> ProtoUInt64Wrapper.serializer().descriptor + RMDeclarationUrl.BOOL_VALUE -> ProtoBoolWrapper.serializer().descriptor + RMDeclarationUrl.FLOAT_VALUE -> ProtoFloatWrapper.serializer().descriptor + RMDeclarationUrl.DOUBLE_VALUE -> ProtoDoubleWrapper.serializer().descriptor + RMDeclarationUrl.STRING_VALUE -> ProtoStringWrapper.serializer().descriptor + RMDeclarationUrl.TIMESTAMP -> ProtoTimestamp.serializer().descriptor + RMDeclarationUrl.DURATION -> ProtoDuration.serializer().descriptor + RMDeclarationUrl.ANY -> ProtoAny.serializer().descriptor + RMDeclarationUrl.STRUCT, RMDeclarationUrl.STRUCT_MAP -> ProtoStruct.serializer().descriptor + RMDeclarationUrl.STRUCT_VALUE -> ProtoStructValue.serializer().descriptor + RMDeclarationUrl.STRUCT_LIST -> ProtoStructValueKind.ListValue.serializer().descriptor + RMDeclarationUrl.STRUCT_NULL -> ProtoStructValueKind.NullValue.serializer().descriptor + RMDeclarationUrl.ACK, RMDeclarationUrl.EMPTY -> ProtoEmpty.serializer().descriptor + else -> { + when (val type = resolver.resolveType(field.typeUrl)!!) { + is RSType.Enclosing -> ProtoEmpty.serializer().descriptor + is RSType.Message -> getOrCreateMessageDescriptor(type) + is RSType.Enum -> getOrCreateEnumDescriptor(type) + } + + } + } + }.forEach { (field, descriptor) -> + element( + elementName = field.name, + descriptor = descriptor, + annotations = listOf(ProtoNumber(field.tag)) + ) + } + } + + return proxyDescriptor + } +} \ No newline at end of file diff --git a/internal/dynamic-serialization/src/commonMain/kotlin/org/timemates/rrpc/generator/kotlin/dyser/MessageValues.kt b/internal/dynamic-serialization/src/commonMain/kotlin/org/timemates/rrpc/generator/kotlin/dyser/MessageValues.kt new file mode 100644 index 0000000..1a78bd2 --- /dev/null +++ b/internal/dynamic-serialization/src/commonMain/kotlin/org/timemates/rrpc/generator/kotlin/dyser/MessageValues.kt @@ -0,0 +1,201 @@ +package org.timemates.rrpc.generator.kotlin.dyser + +/** + * Represents the values of a message in the form of a map of tag numbers to field values. + * + * @property values The map containing the field values, indexed by their tag numbers. + */ +public class MessageValues( + private val values: Map, +) { + public companion object { + public fun create(block: Builder.() -> Unit): MessageValues { + return Builder().apply(block).build() + } + } + + /** + * Retrieves an integer value by its tag number. + * + * @param tag The tag number of the field. + * @return The integer value, or 0 if the field is not present. + */ + public fun getInt(tag: Int): Int { + return values[tag] as? Int ?: 0 + } + + /** + * Retrieves a string value by its tag number. + * + * @param tag The tag number of the field. + * @return The string value, or an empty string if the field is not present. + */ + public fun getString(tag: Int): String { + return values[tag] as? String ?: "" + } + + /** + * Retrieves a long value by its tag number. + * + * @param tag The tag number of the field. + * @return The long value, or 0L if the field is not present. + */ + public fun getLong(tag: Int): Long { + return values[tag] as? Long ?: 0L + } + + /** + * Retrieves a boolean value by its tag number. + * + * @param tag The tag number of the field. + * @return The boolean value, or false if the field is not present. + */ + public fun getBoolean(tag: Int): Boolean { + return values[tag] as? Boolean == true + } + + /** + * Retrieves a double value by its tag number. + * + * @param tag The tag number of the field. + * @return The double value, or 0.0 if the field is not present. + */ + public fun getDouble(tag: Int): Double { + return values[tag] as? Double ?: 0.0 + } + + /** + * Retrieves an unsigned long value by its tag number. + * + * @param tag The tag number of the field. + * @return The unsigned long value, or 0UL if the field is not present. + */ + public fun getULong(tag: Int): ULong { + return values[tag] as? ULong ?: 0UL + } + + /** + * Retrieves a float value by its tag number. + * + * @param tag The tag number of the field. + * @return The float value, or 0.0f if the field is not present. + */ + public fun getFloat(tag: Int): Float { + return values[tag] as? Float ?: 0.0f + } + + /** + * Retrieves an unsigned integer value by its tag number. + * + * @param tag The tag number of the field. + * @return The unsigned integer value, or 0U if the field is not present. + */ + public fun getUInt(tag: Int): UInt { + return values[tag] as? UInt ?: 0U + } + + /** + * Retrieves a byte array value by its tag number. + * + * @param tag The tag number of the field. + * @return The byte array value, or null if the field is not present. + */ + public fun getBytes(tag: Int): ByteArray { + return values[tag] as? ByteArray ?: byteArrayOf() + } + + /** + * Retrieves a nested message value by its tag number. + * + * @param tag The tag number of the field. + * @return The nested message value, or null if the field is not present. + */ + public fun getMessage(tag: Int): MessageValues? { + return values[tag] as? MessageValues + } + + public fun getList(tag: Int): List { + @Suppress("UNCHECKED_CAST") + return values[tag] as? List ?: emptyList() + } + + public fun getMap(tag: Int): Map { + @Suppress("UNCHECKED_CAST") + return values[tag] as? Map ?: mapOf() + } + + public fun isNull(tag: Int): Boolean { + return !values.containsKey(tag) || values[tag] == null + } + + /** + * A builder class for constructing a [MessageValues] instance. + * + * @property values A mutable map for storing the field values indexed by their tag numbers. + */ + public class Builder( + private val values: MutableMap = mutableMapOf(), + ) { + + public operator fun set(tag: Int, value: Int) { + values[tag] = value + } + + public operator fun set(tag: Int, value: String) { + values[tag] = value + } + + public operator fun set(tag: Int, value: Long) { + values[tag] = value + } + + public operator fun set(tag: Int, value: Boolean) { + values[tag] = value + } + + public operator fun set(tag: Int, value: Double) { + values[tag] = value + } + + public operator fun set(tag: Int, value: ULong) { + values[tag] = value + } + + public operator fun set(tag: Int, value: Float) { + values[tag] = value + } + + public operator fun set(tag: Int, value: UInt) { + values[tag] = value + } + + public operator fun set(tag: Int, value: ByteArray) { + values[tag] = value + } + + /** + * Sets a nested message value by its tag number. + * + * @param tag The tag number of the field. + * @param builder A function for constructing the nested message value. + */ + public fun set(tag: Int, builder: Builder.() -> Unit) { + values[tag] = Builder().apply(builder).build() + } + + public operator fun set(tag: Int, list: List) { + values[tag] = list + } + + public fun setRaw(tag: Int, any: Any?) { + any?.let { values[tag] = it } + } + + /** + * Builds and returns a [MessageValues] instance with the stored field values. + * + * @return A [MessageValues] instance containing the field values. + */ + public fun build(): MessageValues = MessageValues(values.toMap()) + } +} \ No newline at end of file diff --git a/internal/dynamic-serialization/src/commonMain/kotlin/org/timemates/rrpc/generator/kotlin/dyser/MessageValuesSerializer.kt b/internal/dynamic-serialization/src/commonMain/kotlin/org/timemates/rrpc/generator/kotlin/dyser/MessageValuesSerializer.kt new file mode 100644 index 0000000..dae13a9 --- /dev/null +++ b/internal/dynamic-serialization/src/commonMain/kotlin/org/timemates/rrpc/generator/kotlin/dyser/MessageValuesSerializer.kt @@ -0,0 +1,159 @@ +package org.timemates.rrpc.generator.kotlin.dyser + +import com.google.protobuf.* +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import org.timemates.rrpc.common.schema.RSResolver +import org.timemates.rrpc.common.schema.RSType +import org.timemates.rrpc.common.schema.value.RMDeclarationUrl + +internal class MessageValuesSerializer( + private val type: RSType.Message, + private val factory: MessageSerializerFactory, + private val resolver: RSResolver, + override val descriptor: SerialDescriptor, +) : KSerializer { + + override fun serialize(encoder: Encoder, value: MessageValues) { + val compositeEncoder = encoder.beginStructure(descriptor) + + type.allFields.asSequence().sortedBy { it.tag }.forEach { field -> + if (value.isNull(field.tag)) { + val serializer = getSerializer(field.typeUrl, factory, resolver) + return@forEach compositeEncoder.encodeNullableSerializableElement( + descriptor, field.tag, serializer, null, + ) + } + + if (field.isRepeated) { + val typeSerializer = getSerializer(field.typeUrl, factory, resolver) + val serializer = ListSerializer(typeSerializer) + + compositeEncoder.encodeSerializableElement( + this.descriptor, + field.tag, + serializer, + value.getList(field.tag) + ) + } else if (field.typeUrl.isMap) { + val firstTypeSerializer = getSerializer(field.typeUrl.firstTypeArgument!!, factory, resolver) + val secondTypeSerializer = getSerializer(field.typeUrl.secondTypeArgument!!, factory, resolver) + + val serializer = MapSerializer(firstTypeSerializer, secondTypeSerializer) + + compositeEncoder.encodeSerializableElement( + this.descriptor, + field.tag, + serializer, + value.getMap(field.tag), + ) + } else { + val serializer = getSerializer(field.typeUrl, factory, resolver) + compositeEncoder.encodeSerializableElement( + this.descriptor, + field.tag, + serializer, + when (field.typeUrl) { + RMDeclarationUrl.STRING -> value.getString(field.tag) + RMDeclarationUrl.BYTES -> value.getBytes(field.tag) + RMDeclarationUrl.INT32 -> value.getInt(field.tag) + RMDeclarationUrl.INT64 -> value.getLong(field.tag) + RMDeclarationUrl.UINT32 -> value.getUInt(field.tag) + RMDeclarationUrl.UINT64 -> value.getULong(field.tag) + RMDeclarationUrl.BOOL -> value.getBoolean(field.tag) + RMDeclarationUrl.FLOAT -> value.getFloat(field.tag) + RMDeclarationUrl.DOUBLE -> value.getDouble(field.tag) + RMDeclarationUrl.ACK, RMDeclarationUrl.EMPTY -> ProtoEmpty.serializer() + else -> { + val type = resolver.resolveType(field.typeUrl)!! + return compositeEncoder.encodeNullableSerializableElement( + this.descriptor, + field.tag, + serializer, + when (type) { + is RSType.Enclosing -> Unit + is RSType.Enum -> value.getInt(field.tag) + is RSType.Message -> value.getMessage(field.tag) + }, + ) + } + } + ) + } + } + + compositeEncoder.endStructure(descriptor) + } + + override fun deserialize(decoder: Decoder): MessageValues { + val composite = decoder.beginStructure(descriptor) + return MessageValues.create { + type.allFields.forEach { field -> + val value = if (field.isRepeated) { + composite.decodeSerializableElement( + descriptor = descriptor, + index = field.tag, + deserializer = ListSerializer(getSerializer(field.typeUrl, factory, resolver)), + ) + } else if (field.typeUrl.isMap) { + composite.decodeSerializableElement( + descriptor = descriptor, + index = field.tag, + deserializer = MapSerializer( + getSerializer(field.typeUrl.firstTypeArgument!!, factory, resolver), + getSerializer(field.typeUrl.secondTypeArgument!!, factory, resolver), + ), + ) + } else if (field.typeUrl.isScalar) { + composite.decodeSerializableElement( + descriptor = descriptor, + index = field.tag, + deserializer = getSerializer(field.typeUrl, factory, resolver), + ) + } else { + composite.decodeNullableSerializableElement( + descriptor = descriptor, + index = field.tag, + deserializer = getSerializer(field.typeUrl, factory, resolver), + ) + } + + setRaw(field.tag, value) + } + } + } +} + + +// TODO better handling of primitives to avoid overhead while using encodeNullableSerializableElement for now we keep it for simplicity. +private fun getSerializer( + url: RMDeclarationUrl, + factory: MessageSerializerFactory, + resolver: RSResolver, +): KSerializer { + return when (url) { + RMDeclarationUrl.STRING -> String.serializer() + RMDeclarationUrl.BYTES -> ByteArraySerializer() + RMDeclarationUrl.INT32 -> Int.serializer() + RMDeclarationUrl.INT64 -> Long.serializer() + RMDeclarationUrl.UINT32 -> UInt.serializer() + RMDeclarationUrl.UINT64 -> ULong.serializer() + RMDeclarationUrl.BOOL -> Boolean.serializer() + RMDeclarationUrl.FLOAT -> Float.serializer() + RMDeclarationUrl.DOUBLE -> Double.serializer() + RMDeclarationUrl.ACK, RMDeclarationUrl.EMPTY -> ProtoEmpty.serializer() + else -> { + when (val type = resolver.resolveType(url)!!) { + is RSType.Enum -> Int.serializer() + is RSType.Enclosing -> ProtoEmpty.serializer() + is RSType.Message -> factory.getMessageSerializer(type) + } + } + } as KSerializer +} \ No newline at end of file diff --git a/internal/dynamic-serialization/src/commonMain/kotlin/org/timemates/rrpc/generator/kotlin/dyser/SerialDescriptorProxy.kt b/internal/dynamic-serialization/src/commonMain/kotlin/org/timemates/rrpc/generator/kotlin/dyser/SerialDescriptorProxy.kt new file mode 100644 index 0000000..9188aaf --- /dev/null +++ b/internal/dynamic-serialization/src/commonMain/kotlin/org/timemates/rrpc/generator/kotlin/dyser/SerialDescriptorProxy.kt @@ -0,0 +1,47 @@ +package org.timemates.rrpc.generator.kotlin.dyser + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.SerialKind +import kotlin.properties.Delegates + +internal class SerialDescriptorProxy : SerialDescriptor { + var descriptor: SerialDescriptor by Delegates.notNull() + + @ExperimentalSerializationApi + override val serialName: String + get() = descriptor.serialName + + @ExperimentalSerializationApi + override val kind: SerialKind + get() = descriptor.kind + + @ExperimentalSerializationApi + override val elementsCount: Int + get() = descriptor.elementsCount + + @ExperimentalSerializationApi + override fun getElementName(index: Int): String { + return descriptor.getElementName(index) + } + + @ExperimentalSerializationApi + override fun getElementIndex(name: String): Int { + return descriptor.getElementIndex(name) + } + + @ExperimentalSerializationApi + override fun getElementAnnotations(index: Int): List { + return descriptor.getElementAnnotations(index) + } + + @ExperimentalSerializationApi + override fun getElementDescriptor(index: Int): SerialDescriptor { + return descriptor.getElementDescriptor(index) + } + + @ExperimentalSerializationApi + override fun isElementOptional(index: Int): Boolean { + return descriptor.isElementOptional(index) + } +} \ No newline at end of file diff --git a/server-core/src/commonMain/kotlin/org/timemates/rsproto/metadata/ExtraMetadata.kt b/server-core/src/commonMain/kotlin/org/timemates/rsproto/metadata/ExtraMetadata.kt deleted file mode 100644 index aa84008..0000000 --- a/server-core/src/commonMain/kotlin/org/timemates/rsproto/metadata/ExtraMetadata.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.timemates.rsproto.metadata - -import kotlin.coroutines.CoroutineContext - -/** - * Represents extra metadata within incoming request associated with a coroutine context. - * - * @property extra The map containing the extra metadata. - */ -@JvmInline -public value class ExtraMetadata(public val extra: Map) : CoroutineContext.Element { - public companion object Key : CoroutineContext.Key - - override val key: CoroutineContext.Key<*> - get() = Key -} \ No newline at end of file diff --git a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/RSocketProtoServer.kt b/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/RSocketProtoServer.kt deleted file mode 100644 index d72523b..0000000 --- a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/RSocketProtoServer.kt +++ /dev/null @@ -1,189 +0,0 @@ -package org.timemates.rsproto.server - -import io.ktor.server.routing.* -import io.ktor.utils.io.core.* -import io.rsocket.kotlin.RSocketError -import io.rsocket.kotlin.RSocketRequestHandler -import io.rsocket.kotlin.RSocketRequestHandlerBuilder -import io.rsocket.kotlin.ktor.server.rSocket -import io.rsocket.kotlin.payload.Payload -import org.timemates.rsproto.metadata.ExtraMetadata -import org.timemates.rsproto.metadata.Metadata -import org.timemates.rsproto.server.annotations.ExperimentalInstancesApi -import org.timemates.rsproto.server.annotations.ExperimentalInterceptorsApi -import org.timemates.rsproto.server.descriptors.ProcedureDescriptor -import org.timemates.rsproto.server.descriptors.ServiceDescriptor -import org.timemates.rsproto.server.descriptors.procedure -import org.timemates.rsproto.server.instances.InstanceContainer -import org.timemates.rsproto.server.instances.ProtobufInstance -import org.timemates.rsproto.server.instances.ProvidableInstance -import org.timemates.rsproto.server.instances.getInstance -import org.timemates.rsproto.server.interceptors.Interceptor -import org.timemates.rsproto.server.interceptors.InterceptorScope -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.withContext -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.decodeFromByteArray - -/** - * Represents a Proto server that can handle remote method calls. - * - * @property services The list of service descriptors for the server. - * @property interceptors The list of interceptors for the server. - */ -public interface RSocketProtoServer : InstanceContainer { - /** - * Represents a list of service descriptors for remote services. - * - * A `ServiceDescriptor` represents a service descriptor for a remote service. It contains the name of the service and a list of procedure descriptors for the service. - * - * @since 1.0 - */ - public val services: List - - /** - * Contains the list of interceptors for the RSocketProtoServer. - * - * Interceptors are used to intercept and modify the coroutine context and payload of remote method calls. - * They are applied before the method is executed and can be used to perform actions such as authentication, logging, - * or modifying the payload of the incoming request. - * - * Interceptors are instances of the [Interceptor] interface. - * - * @see Interceptor - * @see RSocketProtoServer - */ - @OptIn(ExperimentalInterceptorsApi::class) - public val interceptors: List -} - -/** - * Represents a list of known procedure descriptors for the RSocketProtoServer. - * - * The `knownProcedures` property is a read-only property that returns a list of ProcedureDescriptor objects. - * These represent the known procedures for the RSocketProtoServer. Each ProcedureDescriptor represents a remote - * method call and contains information such as the name of the method, the kind of request, and the serializers - * for the request and response objects. - * - * @return The list of known procedure descriptors. - * - * @see ProcedureDescriptor - * @see RSocketProtoServer - */ -public val RSocketProtoServer.knownProcedures: List - get() { - return services.fold(emptyList()) { acc, descriptor -> - acc + descriptor.procedures - } - } - -/** - * Creates and configures an RSocket server with the specified endpoint and RSocketProtoServer. - * - * @param endpoint The endpoint to bind the server to. Default value is*/ -public fun Routing.rSocketServer(endpoint: String = "/rsocket", server: RSocketProtoServer) { - rSocket(endpoint) { - RSocketRequestHandler { - useServer(server) - } - } -} - -/** - * Creates an RSocket server endpoint on the specified routing path. - * - * @param endpoint The routing path for the RSocket server (default:*/ -public fun Routing.rSocketServer(endpoint: String = "/rsocket", block: RSocketProtoServerBuilder.() -> Unit) { - rSocketServer(endpoint, RSocketProtoServerBuilder().apply(block).build()) -} - - -@OptIn(ExperimentalSerializationApi::class, ExperimentalInstancesApi::class) -public fun RSocketRequestHandlerBuilder.useServer(server: RSocketProtoServer) { - val services = server.services - .associateBy { service -> - service.name - } - val protobuf = server.getInstance(ProtobufInstance)!!.protoBuf - - val getMetadata: (Payload) -> Metadata = { - protobuf.decodeFromByteArray(it.metadataOrFailure()) - } - val getService: (Metadata) -> ServiceDescriptor = { - services[it.serviceName] ?: throwServiceNotFound() - } - - requestResponse { payload -> - val metadata = getMetadata(payload) - - val service: ServiceDescriptor = getService(metadata) - val method = service.procedure(metadata.procedureName) - ?: throwProcedureNotFound() - - server.runInterceptors(metadata) { - withContext(ExtraMetadata(metadata.extra)) { - method.execute(protobuf, payload.data) - } - } - } - - requestStream { payload -> - val metadata = getMetadata(payload) - - val service: ServiceDescriptor = getService(metadata) - val method = service.procedure(metadata.procedureName) - ?: throwProcedureNotFound() - - server.runInterceptors(metadata) { - withContext(ExtraMetadata(metadata.extra)) { - method.execute(protobuf, payload.data) - } - } - } - - requestChannel { initial, payloads -> - val metadata = getMetadata(initial) - - val service: ServiceDescriptor = getService(metadata) - val method = service.procedure(metadata.procedureName) - ?: throwProcedureNotFound() - - server.runInterceptors(metadata) { - withContext(ExtraMetadata(metadata.extra)) { - method.execute(protobuf, initial.data, payloads.map { it.data }) - } - } - } -} - -private fun Payload.metadataOrFailure(): ByteArray { - return metadata?.readBytes() - ?: throw RSocketError.Invalid("Metadata with service and procedure is not specified.") -} - -private fun throwServiceNotFound(): Nothing = throw RSocketError.Invalid("Service is not found.") -private fun throwProcedureNotFound(): Nothing = throw RSocketError.Invalid("Procedure is not found.") - -@OptIn(ExperimentalInstancesApi::class, ExperimentalInterceptorsApi::class) -internal class RSocketProtoServerImpl( - override val services: List, - override val interceptors: List, - override val instances: Map, ProvidableInstance> -) : RSocketProtoServer - -@OptIn(ExperimentalInterceptorsApi::class) -private suspend inline fun RSocketProtoServer.runInterceptors(metadata: Metadata, crossinline block: suspend () -> R): R { - val scope = InterceptorScope(this) - var coroutineContext = currentCoroutineContext() - - interceptors.forEach { inteceptor -> - with(inteceptor) { - coroutineContext = scope.intercept(coroutineContext, metadata) - } - } - - return withContext(coroutineContext) { - block() - } -} \ No newline at end of file diff --git a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/RSocketProtoServerBuilder.kt b/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/RSocketProtoServerBuilder.kt deleted file mode 100644 index 7aa2a94..0000000 --- a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/RSocketProtoServerBuilder.kt +++ /dev/null @@ -1,63 +0,0 @@ -package org.timemates.rsproto.server - -import org.timemates.rsproto.server.annotations.ExperimentalInstancesApi -import org.timemates.rsproto.server.annotations.ExperimentalInterceptorsApi -import org.timemates.rsproto.server.instances.ProvidableInstance -import org.timemates.rsproto.server.instances.protobuf -import org.timemates.rsproto.server.interceptors.Interceptor -import kotlinx.serialization.ExperimentalSerializationApi - -public class RSocketProtoServerBuilder internal constructor() { - @OptIn(ExperimentalInstancesApi::class) - private val instances: MutableList = mutableListOf() - private val services: MutableList = mutableListOf() - @OptIn(ExperimentalInterceptorsApi::class) - private val interceptors: MutableList = mutableListOf() - - public fun service(service: RSocketService) { - services += service - } - - @ExperimentalInterceptorsApi - public fun interceptor(interceptor: Interceptor) { - interceptors += interceptor - } - - @ExperimentalInstancesApi - public fun instances(block: InstancesBuilder.() -> Unit) { - instances += InstancesBuilder().apply(block).build() - } - - @OptIn(ExperimentalInterceptorsApi::class, ExperimentalInstancesApi::class) - public fun build(): RSocketProtoServer { - return RSocketProtoServerImpl( - services = services.map { it.descriptor }, - interceptors = interceptors.toList(), - instances = instances.associateBy { it.key }, - ) - } - - @ExperimentalInstancesApi - public class InstancesBuilder internal constructor() { - private val instances: MutableList = mutableListOf() - - public fun register(instance: ProvidableInstance) { - instances += instance - } - - internal fun build(): List { - return instances.toList() - } - } -} - -@OptIn(ExperimentalInstancesApi::class, ExperimentalSerializationApi::class) -public fun RSocketProtoServer(block: RSocketProtoServerBuilder.() -> Unit): RSocketProtoServer { - return RSocketProtoServerBuilder().apply { - instances { - protobuf { - encodeDefaults = true - } - } - }.apply(block).build() -} \ No newline at end of file diff --git a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/RSocketService.kt b/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/RSocketService.kt deleted file mode 100644 index 4487567..0000000 --- a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/RSocketService.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.timemates.rsproto.server - -import org.timemates.rsproto.metadata.ExtraMetadata -import org.timemates.rsproto.server.annotations.ExperimentalInstancesApi -import org.timemates.rsproto.server.descriptors.ProcedureDescriptor -import org.timemates.rsproto.server.descriptors.ServiceDescriptor -import org.timemates.rsproto.server.instances.CoroutineContextInstanceContainer -import org.timemates.rsproto.server.instances.ProvidableInstance -import org.timemates.rsproto.server.instances.getInstance -import kotlin.coroutines.coroutineContext - -/** - * Annotation-marker for the services that are generated. - */ -public abstract class RSocketService { - /** - * Represents a descriptor for a service. Contains name, procedures and so on. - * - * @see ProcedureDescriptor - */ - public abstract val descriptor: ServiceDescriptor - - - /** - * Retrieves an instance of the specified type based on the provided key. - * - * **Note**: Works only within service procedures as instances are provided via - * [kotlin.coroutines.CoroutineContext]. - * - * @param key The key representing the type of instance to retrieve. - * @return The instance of type T, or null if it doesn't exist. - */ - @ExperimentalInstancesApi - protected suspend fun getInstance(key: ProvidableInstance.Key): T? { - return coroutineContext[CoroutineContextInstanceContainer]?.container?.getInstance(key) - } - - protected suspend fun getExtras(): Map = - coroutineContext[ExtraMetadata]?.extra ?: emptyMap() -} \ No newline at end of file diff --git a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/annotations/ExperimentalInstancesApi.kt b/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/annotations/ExperimentalInstancesApi.kt deleted file mode 100644 index 4e56706..0000000 --- a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/annotations/ExperimentalInstancesApi.kt +++ /dev/null @@ -1,4 +0,0 @@ -package org.timemates.rsproto.server.annotations - -@RequiresOptIn(message = "This API is under consideration.", level = RequiresOptIn.Level.ERROR) -public annotation class ExperimentalInstancesApi \ No newline at end of file diff --git a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/annotations/ExperimentalInterceptorsApi.kt b/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/annotations/ExperimentalInterceptorsApi.kt deleted file mode 100644 index 91fe527..0000000 --- a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/annotations/ExperimentalInterceptorsApi.kt +++ /dev/null @@ -1,4 +0,0 @@ -package org.timemates.rsproto.server.annotations - -@RequiresOptIn(message = "This API has subject to change.", level = RequiresOptIn.Level.ERROR) -public annotation class ExperimentalInterceptorsApi \ No newline at end of file diff --git a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/descriptors/ProcedureDescriptor.kt b/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/descriptors/ProcedureDescriptor.kt deleted file mode 100644 index c39b4ef..0000000 --- a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/descriptors/ProcedureDescriptor.kt +++ /dev/null @@ -1,70 +0,0 @@ -@file:Suppress("MemberVisibilityCanBePrivate") -@file:OptIn(ExperimentalSerializationApi::class, ExperimentalSerializationApi::class) - -package org.timemates.rsproto.server.descriptors - -import io.ktor.utils.io.core.* -import io.rsocket.kotlin.payload.Payload -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import kotlinx.serialization.DeserializationStrategy -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerializationStrategy -import kotlinx.serialization.protobuf.ProtoBuf - -public sealed interface ProcedureDescriptor { - public val name: String - public val inputSerializer: DeserializationStrategy - public val outputSerializer: SerializationStrategy - - public data class RequestResponse( - override val name: String, - override val inputSerializer: KSerializer, - override val outputSerializer: KSerializer, - private val procedure: suspend (Any) -> Any, - ) : ProcedureDescriptor { - public suspend fun execute( - protoBuf: ProtoBuf = ProtoBuf, - request: ByteReadPacket, - ): Payload { - return protoBuf.encodeToByteArray( - serializer = outputSerializer, - value = procedure(protoBuf.decodeFromByteArray(inputSerializer, request.readBytes())), - ).let { Payload(ByteReadPacket(it)) } - } - } - - public data class RequestStream( - override val name: String, - override val inputSerializer: DeserializationStrategy, - override val outputSerializer: SerializationStrategy, - private val procedure: suspend (Any) -> Flow, - ) : ProcedureDescriptor { - public suspend fun execute( - protoBuf: ProtoBuf = ProtoBuf, - request: ByteReadPacket, - ): Flow { - return procedure(protoBuf.decodeFromByteArray(inputSerializer, request.readBytes())) - .map { Payload(ByteReadPacket(protoBuf.encodeToByteArray(outputSerializer, it))) } - } - } - - public data class RequestChannel( - override val name: String, - override val inputSerializer: DeserializationStrategy, - override val outputSerializer: SerializationStrategy, - private val procedure: suspend (Any, Flow) -> Flow, - ) : ProcedureDescriptor { - public suspend fun execute( - protoBuf: ProtoBuf = ProtoBuf, - init: ByteReadPacket, - incoming: Flow, - ): Flow { - return procedure( - protoBuf.decodeFromByteArray(inputSerializer, init.readBytes()), - incoming.map { protoBuf.decodeFromByteArray(inputSerializer, it.readBytes()) } - ).map { Payload(ByteReadPacket(protoBuf.encodeToByteArray(outputSerializer, it))) } - } - } -} \ No newline at end of file diff --git a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/instances/InstanceContainer.kt b/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/instances/InstanceContainer.kt deleted file mode 100644 index 89c8497..0000000 --- a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/instances/InstanceContainer.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.timemates.rsproto.server.instances - -import org.timemates.rsproto.server.annotations.ExperimentalInstancesApi -import kotlin.coroutines.CoroutineContext - -public interface InstanceContainer { - /** - * Represents a map of instances that can be provided based on a specified key. - * - * @see ProvidableInstance - * @see ProvidableInstance.Key - * - * @since 1.0 - */ - @ExperimentalInstancesApi - public val instances: Map, ProvidableInstance> -} - -@ExperimentalInstancesApi -@Suppress("UNCHECKED_CAST") -public fun InstanceContainer.getInstance(key: ProvidableInstance.Key): T? = - instances[key] as? T - - -internal class CoroutineContextInstanceContainer( - val container: InstanceContainer, -) : CoroutineContext.Element { - override val key: CoroutineContext.Key<*> = Key - - companion object Key : CoroutineContext.Key -} \ No newline at end of file diff --git a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/interceptors/Interceptor.kt b/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/interceptors/Interceptor.kt deleted file mode 100644 index 6da75d9..0000000 --- a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/interceptors/Interceptor.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.timemates.rsproto.server.interceptors - -import org.timemates.rsproto.metadata.Metadata -import org.timemates.rsproto.server.annotations.ExperimentalInterceptorsApi -import org.timemates.rsproto.server.instances.InstanceContainer -import kotlin.coroutines.CoroutineContext - -/** - * Represents an interceptor that can be used to intercept and modify the coroutine - * context and payload. - * - * @since 1.0 - */ -@ExperimentalInterceptorsApi -public abstract class Interceptor { - /** - * Calls the `intercept` method of an interceptor to intercept and modify the coroutine context and payload. - * - * @param coroutineContext The current coroutine context. - * @return The modified coroutine context. - */ - public abstract fun InterceptorScope.intercept( - coroutineContext: CoroutineContext, - metadata: Metadata, - ): CoroutineContext -} - -public data class InterceptorScope( - public val instances: InstanceContainer, -) \ No newline at end of file diff --git a/server-core/build.gradle.kts b/server/core/build.gradle.kts similarity index 64% rename from server-core/build.gradle.kts rename to server/core/build.gradle.kts index 509907e..cc40fb0 100644 --- a/server-core/build.gradle.kts +++ b/server/core/build.gradle.kts @@ -3,28 +3,34 @@ plugins { alias(libs.plugins.kotlinx.serialization) } -group = "org.timemates.rsproto" +group = "org.timemates.rrpc" version = System.getenv("LIB_VERSION") ?: "SNAPSHOT" dependencies { - commonMainApi(libs.rsocket.server) - commonMainApi(libs.kotlinx.serialization.proto) - commonMainApi(projects.commonCore) + // -- Project -- + commonMainImplementation(projects.common.core) + // -- Ktor -- + commonMainImplementation(libs.ktor.server.core) commonMainImplementation(libs.ktor.server.websockets) - commonMainImplementation(libs.ktor.server.core) + // -- RSocket -- + commonMainApi(libs.rsocket.server) + + // -- Serialization -- + commonMainImplementation(libs.kotlinx.serialization.proto) } + mavenPublishing { coordinates( - groupId = "org.timemates.rsproto", + groupId = "org.timemates.rrpc", artifactId = "server-core", version = System.getenv("LIB_VERSION") ?: return@mavenPublishing, ) pom { - name.set("RSProto Client Core") - description.set("Multiplatform Kotlin core library for RSProto servers.") + name.set("RRpc Server Core") + description.set("Multiplatform Kotlin core library for RRpc servers.") } -} \ No newline at end of file +} diff --git a/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/OptionsContainer.kt b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/OptionsContainer.kt new file mode 100644 index 0000000..9eb5b04 --- /dev/null +++ b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/OptionsContainer.kt @@ -0,0 +1,69 @@ +package org.timemates.rrpc.server + +import org.timemates.rrpc.options.Option +import org.timemates.rrpc.options.OptionsWithValue + +/** + * An interface representing a container that holds a list of options. + */ +public interface OptionsContainer { + /** + * The list of options contained within this container. + */ + public val options: OptionsWithValue + + /** + * Checks if the specified option is present in the container. + * + * @param option The option to check for. + * @return True if the option is present, false otherwise. + */ + public fun hasOption(option: Option<*>): Boolean + + /** + * Retrieves the value of the specified option. + * + * @param option The option to retrieve. + * @return The value of the specified option. + * @throws NoSuchElementException if the option is not present. + */ + public fun getOption(option: Option): T +} + +/** + * Retrieves the value of the specified option if it is present, or null if it is not. + * + * @param option The option to retrieve. + * @return The value of the option, or null if the option is not present. + */ +public fun OptionsContainer.getOptionOrNull(option: Option): T? { + return if (hasOption(option)) getOption(option) else null +} + +/** + * Retrieves the value of the specified option if it is present, or a default value if it is not. + * + * @param option The option to retrieve. + * @param defaultValue The default value to return if the option is not present. + * @return The value of the option, or the default value if the option is not present. + */ +public fun OptionsContainer.getOptionOrElse( + option: Option, + defaultValue: () -> T +): T { + return if (hasOption(option)) getOption(option) else defaultValue() +} + +internal fun optionsContainer( + map: OptionsWithValue, +): OptionsContainer = OptionsContainerImpl(map) + +@JvmInline +internal value class OptionsContainerImpl( + override val options: OptionsWithValue +) : OptionsContainer { + override fun hasOption(option: Option<*>): Boolean = options[option] != null + + @Suppress("UNCHECKED_CAST") + override fun getOption(option: Option): T = options[option] as T +} \ No newline at end of file diff --git a/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/RequestContext.kt b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/RequestContext.kt new file mode 100644 index 0000000..c95cfbf --- /dev/null +++ b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/RequestContext.kt @@ -0,0 +1,26 @@ +package org.timemates.rrpc.server + +import org.timemates.rrpc.instances.InstanceContainer +import org.timemates.rrpc.interceptors.InterceptorContext +import org.timemates.rrpc.metadata.ClientMetadata +import org.timemates.rrpc.options.OptionsWithValue + +/** + * Represents the context of a request, encapsulating relevant data such as + * instances, metadata, and options for configuring the request behavior. + * + * @property instances A container for instances relevant to this request. + * @property metadata Metadata that provides client-specific information. + * @property options Configurable on schema-level, options that may affect request handling. + */ +public data class RequestContext( + public val instances: InstanceContainer, + public val metadata: ClientMetadata, + public val options: OptionsWithValue, +) + +internal fun InterceptorContext.toRequestContext(): RequestContext { + return RequestContext( + instances, metadata, options, + ) +} \ No newline at end of file diff --git a/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/ServicesContainer.kt b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/ServicesContainer.kt new file mode 100644 index 0000000..109599c --- /dev/null +++ b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/ServicesContainer.kt @@ -0,0 +1,35 @@ +package org.timemates.rrpc.server + +import org.timemates.rrpc.instances.ProvidableInstance +import org.timemates.rrpc.interceptors.InterceptorContext +import org.timemates.rrpc.metadata.ClientMetadata +import org.timemates.rrpc.server.module.descriptors.ServiceDescriptor + +public interface ServicesContainer : ProvidableInstance { + public companion object Key : ProvidableInstance.Key + /** + * Represents a list of service descriptors for remote services. + * + * A `ServiceDescriptor` represents a service descriptor for a remote service. + * It contains the name of the service and a list of procedure descriptors for the service. + * + * @since 1.0 + */ + public val services: List + + /** + * Gets service by [name]. + */ + public fun service(name: String): ServiceDescriptor? +} + +/** + * Resolves service by incoming metadata and instance container. + */ +public val InterceptorContext.service: ServiceDescriptor + get() = instances.getInstance(ServicesContainer)?.service(metadata.serviceName) + ?: error( + "Unable to resolve the service: InstanceContainer is not including needed ServiceContainer or it's different" + + " instance without origin service. The only reason why it may happen: some of the interceptors " + + "replaced with a new InstanceContainer without taking in count ServiceContainer." + ) \ No newline at end of file diff --git a/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/RRpcModule.kt b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/RRpcModule.kt new file mode 100644 index 0000000..8051a9e --- /dev/null +++ b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/RRpcModule.kt @@ -0,0 +1,77 @@ +package org.timemates.rrpc.server.module + +import org.timemates.rrpc.server.ServicesContainer +import org.timemates.rrpc.annotations.ExperimentalInterceptorsApi +import org.timemates.rrpc.instances.InstanceContainer +import org.timemates.rrpc.instances.ProvidableInstance +import org.timemates.rrpc.interceptors.Interceptors +import org.timemates.rrpc.server.OptionsContainer +import org.timemates.rrpc.server.module.descriptors.ProcedureDescriptor +import org.timemates.rrpc.server.module.descriptors.ServiceDescriptor + +/** + * Represents a Proto server that can handle remote method calls. + * + * @property services The list of service descriptors for the server. + * @property interceptors The list of interceptors for the server. + */ +public interface RRpcModule : InstanceContainer, ServicesContainer { + /** + * Contains the list of interceptors for the RSocketProtoServer. + * + * Interceptors are used to intercept and modify the coroutine context and payload of remote method calls. + * They are applied before the method is executed and can be used to perform actions such as authentication, logging, + * or modifying the payload of the incoming request. + * + * Interceptors are instances of the [org.timemates.rrpc.interceptors.Interceptor] interface. + * + * @see [org.timemates.rrpc.interceptors.Interceptor] + */ + @ExperimentalInterceptorsApi + public val interceptors: Interceptors +} + +/** + * Represents a list of known procedure descriptors for the RSocketProtoServer. + * + * The `knownProcedures` property is a read-only property that returns a list of ProcedureDescriptor objects. + * These represent the known procedures for the RSocketProtoServer. Each ProcedureDescriptor represents a remote + * method call and contains information such as the name of the method, the kind of request, and the serializers + * for the request and response objects. + * + * @return The list of known procedure descriptors. + * + * @see ProcedureDescriptor + * @see RRpcModule + */ +public val RRpcModule.knownProcedures: List + get() = services.flatMap { it.procedures } + +/** + * Internal implementation of the RRpcModule interface. + * + * @property services The list of service descriptors for the server. + * @property interceptors The list of interceptors for the server. + * @param instanceContainer The instance container. + */ +@OptIn(ExperimentalInterceptorsApi::class) +internal class RRpcModuleImpl( + override val services: List, + override val interceptors: Interceptors, + instanceContainer: InstanceContainer +) : RRpcModule, InstanceContainer { + private val servicesMap = services.associateBy { it.name } + private val instanceContainer = instanceContainer + this as ServicesContainer + + override val key: ProvidableInstance.Key<*> + get() = ServicesContainer.Key + + override fun service(name: String): ServiceDescriptor? = servicesMap[name] + + override fun getInstance(key: ProvidableInstance.Key): T? = + instanceContainer.getInstance(key) + override fun plus(instance: ProvidableInstance): InstanceContainer = instanceContainer + instance + override fun plus(instances: List): InstanceContainer = instanceContainer + instances + override fun plus(container: InstanceContainer): InstanceContainer = instanceContainer + container + override fun asMap(): Map, ProvidableInstance> = instanceContainer.asMap() +} diff --git a/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/RRpcModuleBuilder.kt b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/RRpcModuleBuilder.kt new file mode 100644 index 0000000..5a84c20 --- /dev/null +++ b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/RRpcModuleBuilder.kt @@ -0,0 +1,130 @@ +package org.timemates.rrpc.server.module + +import kotlinx.serialization.ExperimentalSerializationApi +import org.timemates.rrpc.annotations.ExperimentalInterceptorsApi +import org.timemates.rrpc.annotations.InternalRRpcAPI +import org.timemates.rrpc.instances.InstanceContainer +import org.timemates.rrpc.instances.InstancesBuilder +import org.timemates.rrpc.instances.protobuf +import org.timemates.rrpc.interceptors.Interceptors +import org.timemates.rrpc.interceptors.RequestInterceptor +import org.timemates.rrpc.interceptors.ResponseInterceptor + +/** + * Creates an [RRpcModule] using the provided [block] to configure its builder. + * + * @param block A lambda with receiver of type [RRpcModuleBuilder] used to configure the module. + * @return A configured [RRpcModule]. + */ +@OptIn(ExperimentalSerializationApi::class) +public fun RRpcModule(block: RRpcModuleBuilder.() -> Unit): RRpcModule { + return RRpcModuleBuilder().apply { + instances { + protobuf { + encodeDefaults = true + } + } + }.apply(block).build() +} + +/** + * Builder class for constructing an [RRpcModule]. + */ +@OptIn(InternalRRpcAPI::class) +public class RRpcModuleBuilder internal constructor() { + private val instances: InstancesBuilder = InstancesBuilder() + private val services: ServicesBuilder = ServicesBuilder() + @OptIn(ExperimentalInterceptorsApi::class) + private val interceptors: InterceptorsBuilder = InterceptorsBuilder() + + public fun services(builder: ServicesBuilder.() -> Unit) { + services.apply(builder).build() + } + + @ExperimentalInterceptorsApi + public fun interceptors(builder: InterceptorsBuilder.() -> Unit) { + interceptors.builder() + } + + /** + * Configures instances for the module using the provided [block]. + * + * @param block A lambda with receiver of type [InstancesBuilder] used to configure instances. + */ + public fun instances(block: InstancesBuilder.() -> Unit) { + instances.apply(block).build() + } + + /** + * Builds the [RRpcModule] with the configured services, interceptors, and instances. + * + * @return A configured [RRpcModule]. + */ + @OptIn(ExperimentalInterceptorsApi::class) + public fun build(): RRpcModule { + return RRpcModuleImpl( + services = services.build().map { it.descriptor }, + interceptors = interceptors.build(), + instanceContainer = InstanceContainer(instances.build().associateBy { it.key }), + ) + } + + public class ServicesBuilder { + private val services: MutableList = mutableListOf() + + public fun register(service: RRpcService) { + services += service + } + + public fun build(): List = services.toList() + } + + @ExperimentalInterceptorsApi + public class InterceptorsBuilder { + private val requestInterceptors: MutableList = mutableListOf() + private val responseInterceptors: MutableList = mutableListOf() + + /** + * Adds a request interceptor to the module. + * + * @param interceptor The [RequestInterceptor] to add. + */ + @ExperimentalInterceptorsApi + public fun request(interceptor: RequestInterceptor) { + requestInterceptors += interceptor + } + + /** + * Adds a response interceptor to the module. + * + * @param interceptor The [ResponseInterceptor] to add. + */ + @ExperimentalInterceptorsApi + public fun response(interceptor: ResponseInterceptor) { + responseInterceptors += interceptor + } + + @ExperimentalInterceptorsApi + public fun build(): Interceptors = Interceptors(requestInterceptors.toList(), responseInterceptors.toList()) + } +} + +/** + * Adds multiple request interceptors to the module. + * + * @param interceptors Vararg of [RequestInterceptor] to add. + */ +@ExperimentalInterceptorsApi +public fun RRpcModuleBuilder.InterceptorsBuilder.request(vararg interceptors: RequestInterceptor) { + interceptors.forEach { request(it) } +} + +/** + * Adds multiple response interceptors to the module. + * + * @param interceptors Vararg of [ResponseInterceptor] to add. + */ +@ExperimentalInterceptorsApi +public fun RRpcModuleBuilder.InterceptorsBuilder.response(vararg interceptors: ResponseInterceptor) { + interceptors.forEach { response(interceptor = it) } +} diff --git a/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/RRpcModuleHandler.kt b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/RRpcModuleHandler.kt new file mode 100644 index 0000000..4ccb6c9 --- /dev/null +++ b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/RRpcModuleHandler.kt @@ -0,0 +1,376 @@ +@file:OptIn(ExperimentalSerializationApi::class, ExperimentalInterceptorsApi::class, InternalRRpcAPI::class) + +package org.timemates.rrpc.server.module + +import io.ktor.utils.io.core.* +import io.rsocket.kotlin.RSocketError +import io.rsocket.kotlin.RSocketRequestHandlerBuilder +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.exceptions.ProcedureNotFoundException +import org.timemates.rrpc.exceptions.ServiceNotFoundException +import org.timemates.rrpc.instances.ProtobufInstance +import org.timemates.rrpc.interceptors.InterceptorContext +import org.timemates.rrpc.metadata.ClientMetadata +import org.timemates.rrpc.metadata.ServerMetadata +import org.timemates.rrpc.options.OptionsWithValue +import org.timemates.rrpc.server.RequestContext +import org.timemates.rrpc.server.module.descriptors.ProcedureDescriptor +import org.timemates.rrpc.server.module.descriptors.ServiceDescriptor +import org.timemates.rrpc.server.module.descriptors.procedure +import org.timemates.rrpc.server.toRequestContext + +/** + * Handler class for setting up RSocket request handlers. + * + * @param module The RRpcModule instance to handle requests. + */ +@Suppress("DuplicatedCode") +public class RRpcModuleHandler(private val module: RRpcModule) { + private val services = module.services.associateBy { it.name } + private val protobuf = module.getInstance(ProtobufInstance)!!.protobuf + private val serverMetadata = ServerMetadata.EMPTY + + /** + * Sets up the RSocketRequestHandler with the necessary request handlers. + * + * @param builder The RSocketRequestHandlerBuilder to set up. + */ + public fun setup(builder: RSocketRequestHandlerBuilder) { + builder.apply { + requestResponseHandler() + requestStreamHandler() + requestChannelHandler() + fireAndForgetHandler() + metadataPushHandler() + } + } + + private fun RSocketRequestHandlerBuilder.requestResponseHandler() { + requestResponse { payload -> + val metadata = getClientMetadata(payload.metadataOrFailure()) + val service = getService(metadata) + val method = service.procedure>(metadata.procedureName) + ?: handleException(ProcedureNotFoundException(metadata), null) + + val options = method.options + + // Decode the input data + val data = try { + Single(protobuf.decodeFromByteArray(method.inputSerializer, payload.data.readBytes())) + } catch (e: Exception) { + handleException(e, method) + } + + // Run input interceptors and handle exceptions + val startContext = module.interceptors.runInputInterceptors( + data = data, + clientMetadata = metadata, + options = options, + module, + ) + + if (startContext?.data is Failure) throw (startContext.data as Failure).exception + + + // Execute the method and handle exceptions + val result = try { + method.execute( + context = startContext?.toRequestContext() ?: RequestContext(module, metadata, options), + input = (startContext?.data ?: data).requireSingle() + ) + } catch (e: Exception) { + handleException(e, method, startContext) + } + + // Run output interceptors and handle exceptions + val finalContext = module.interceptors.runOutputInterceptors( + data = Single(result), + serverMetadata = serverMetadata, + options = startContext?.options ?: options, + instanceContainer = startContext?.instances ?: module, + ) + + if (finalContext?.data is Failure) throw (finalContext.data as Failure).exception + + ((finalContext?.data as? Single)?.value ?: result).toPayload( + strategy = method.outputSerializer, serverMetadata = serverMetadata + ) + } + } + + private fun RSocketRequestHandlerBuilder.requestStreamHandler() { + requestStream { payload -> + val metadata = getClientMetadata(payload.metadataOrFailure()) + val service = getService(metadata) + val method = service.procedure>(metadata.procedureName) + ?: handleException(ProcedureNotFoundException(metadata), null) + + val options = method.options + + // Decode the input data + val data = try { + Single(protobuf.decodeFromByteArray(method.inputSerializer, payload.data.readBytes())) + } catch (e: Exception) { + handleException(e, method) + } + + // Run input interceptors and handle exceptions + val startContext = module.interceptors.runInputInterceptors( + data = data, + clientMetadata = metadata, + options = options, + module, + ) + + if (startContext?.data is Failure) + throw (startContext.data as Failure).exception + + // Execute the method and handle exceptions + val result = try { + method.execute( + context = startContext?.toRequestContext() ?: RequestContext(module, metadata, options), + value = (startContext?.data ?: data).requireSingle() + ) + } catch (e: Exception) { + handleException(e, method, startContext) + } + + val finalContext = module.interceptors.runOutputInterceptors( + data = Streaming(result), + serverMetadata = serverMetadata, + options = startContext?.options ?: options, + instanceContainer = startContext?.instances ?: module, + ) + + if (finalContext?.data is Failure) throw (finalContext.data as Failure).exception + + ((finalContext?.data as? Streaming)?.flow ?: result).mapToPayload( + serverMetadata = serverMetadata, strategy = method.outputSerializer + ) + } + } + + + private fun RSocketRequestHandlerBuilder.requestChannelHandler() { + requestChannel { initial, payloads -> + val metadata = getClientMetadata(initial.metadataOrFailure()) + val service = getService(metadata) + val method = service.procedure>(metadata.procedureName) + ?: handleException(ProcedureNotFoundException(metadata), null) + + val options = method.options + + // Decode the input data + val data = try { + Streaming(payloads.map { + protobuf.decodeFromByteArray(method.inputSerializer, it.data.readBytes()) + }) + } catch (e: Exception) { + handleException(e, method) + } + + // Run input interceptors and handle exceptions + val startContext = + module.interceptors.runInputInterceptors( + data = data, + clientMetadata = metadata, + options = options, + module, + ) + + if (startContext?.data is Failure) throw (startContext.data as Failure).exception + + // Execute the method and handle exceptions + val result = try { + method.execute( + context = startContext?.toRequestContext() ?: RequestContext(module, metadata, options), + flow = (startContext?.data ?: data).requireStreaming() + ) + } catch (e: Exception) { + handleException(e, method, startContext) + } + + // Run output interceptors and handle exceptions + val finalContext = module.interceptors.runOutputInterceptors( + data = Streaming(result), + serverMetadata = serverMetadata, + options = startContext?.options ?: options, + instanceContainer = startContext?.instances ?: module, + ) + + if (finalContext?.data is Failure) throw (finalContext.data as Failure).exception + + ((finalContext?.data as? Streaming)?.flow ?: result).mapToPayload( + serverMetadata = serverMetadata, strategy = method.outputSerializer + ) + } + } + + private fun RSocketRequestHandlerBuilder.fireAndForgetHandler(): Unit = fireAndForget { payload -> + val metadata = getClientMetadata(payload.metadataOrFailure()) + val service = getService(metadata) + val method = service.procedure>(metadata.procedureName) + ?: handleException(ProcedureNotFoundException(metadata), null) + + val options = method.options + + // Decode the input data + val data = try { + Single(protobuf.decodeFromByteArray(method.inputSerializer, payload.data.readBytes())) + } catch (e: Exception) { + handleException(e, method) + } + + // Run input interceptors and handle exceptions + val startContext = module.interceptors.runInputInterceptors( + data = data, + clientMetadata = metadata, + options = options, + module, + ) + + if (startContext?.data is Failure) throw (startContext.data as Failure).exception + + + // Execute the method and handle exceptions + val result = try { + method.execute( + context = startContext?.toRequestContext() ?: RequestContext(module, metadata, options), + input = (startContext?.data ?: data).requireSingle() + ) + } catch (e: Exception) { + handleException(e, method, startContext) + } + + // Run output interceptors and handle exceptions + val finalContext = module.interceptors.runOutputInterceptors( + data = Single(result), + serverMetadata = serverMetadata, + options = startContext?.options ?: options, + instanceContainer = startContext?.instances ?: module, + ) + + // is not propagated to the client + if (finalContext?.data is Failure) throw (finalContext.data as Failure).exception + } + + private fun RSocketRequestHandlerBuilder.metadataPushHandler(): Unit = metadataPush { metadataBytes -> + val metadata = getClientMetadata(metadataBytes.readBytes()) + val service = getService(metadata) + val method = service.procedure(metadata.procedureName) + ?: handleException(ProcedureNotFoundException(metadata), null) + + val options = method.options + + val startContext = module.interceptors.runInputInterceptors( + data = Single.EMPTY, + clientMetadata = metadata, + options = options, + module, + ) + + if (startContext?.data is Failure) throw (startContext.data as Failure).exception + + + try { + method.execute( + context = startContext?.toRequestContext() ?: RequestContext(module, metadata, options), + ) + } catch (e: Exception) { + handleException(e, method, startContext) + } + + // Run output interceptors and handle exceptions + val finalContext = module.interceptors.runOutputInterceptors( + data = Single.EMPTY, + serverMetadata = serverMetadata, + options = startContext?.options ?: options, + instanceContainer = startContext?.instances ?: module, + ) + + // is not propagated to the client + if (finalContext?.data is Failure) throw (finalContext.data as Failure).exception + } + + @OptIn(InternalRRpcAPI::class) + private suspend fun handleException( + exception: Exception, + method: ProcedureDescriptor?, + prevContext: InterceptorContext? = null, + ): Nothing { + val resultException = try { + module.interceptors.runOutputInterceptors( + data = Failure(exception), + serverMetadata = serverMetadata, + options = prevContext?.options ?: method?.options ?: OptionsWithValue.EMPTY, + instanceContainer = prevContext?.instances ?: module, + )?.data?.requireFailure() ?: exception + } catch (e: Exception) { + // todo special logging + e + } + + throw resultException + } + + private fun getClientMetadata(metadata: ByteArray): ClientMetadata { + return try { + protobuf.decodeFromByteArray(metadata) + } catch (e: Exception) { + // TODO logging the exception + throw RSocketError.Rejected("Unable to process incoming request, data is corrupted or invalid.") + } + } + + private fun getService(metadata: ClientMetadata): ServiceDescriptor { + return services[metadata.serviceName] ?: throw ServiceNotFoundException(metadata.serviceName) + } + + /** + * Extension function to convert a value to a Payload. + * + * @param value The value to convert. + * @param strategy The serialization strategy for the value. + * @param serverMetadata The metadata to include with the server response. + * @return The Payload representing the value. + */ + private fun T.toPayload( + strategy: SerializationStrategy, + serverMetadata: ServerMetadata, + ): Payload { + return Payload( + ByteReadPacket(protobuf.encodeToByteArray(strategy, this@toPayload)), + ByteReadPacket(protobuf.encodeToByteArray(ServerMetadata.serializer(), serverMetadata)) + ) + } + + @OptIn(ExperimentalSerializationApi::class) + private fun Flow.mapToPayload( + serverMetadata: ServerMetadata, + strategy: SerializationStrategy, + ): Flow { + return flow { + emit( + Payload( + ByteReadPacket.Empty, ByteReadPacket(protobuf.encodeToByteArray(serverMetadata)) + ) + ) + collect { + emit( + Payload( + data = ByteReadPacket(protobuf.encodeToByteArray(strategy, it)), + ) + ) + } + } + } +} + +internal fun Payload.metadataOrFailure(): ByteArray { + return metadata?.readBytes() ?: throw RSocketError.Invalid("Metadata with service and procedure is not specified.") +} diff --git a/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/RRpcService.kt b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/RRpcService.kt new file mode 100644 index 0000000..5620c36 --- /dev/null +++ b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/RRpcService.kt @@ -0,0 +1,15 @@ +package org.timemates.rrpc.server.module + +import org.timemates.rrpc.server.module.descriptors.ServiceDescriptor + +/** + * Annotation-marker for the services that are generated. + */ +public interface RRpcService { + /** + * Represents a descriptor for a service. Contains name, procedures and so on. + * + * @see ProcedureDescriptor + */ + public val descriptor: ServiceDescriptor +} \ No newline at end of file diff --git a/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/RoutingExt.kt b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/RoutingExt.kt new file mode 100644 index 0000000..44fb317 --- /dev/null +++ b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/RoutingExt.kt @@ -0,0 +1,29 @@ +package org.timemates.rrpc.server.module + +import io.ktor.server.routing.* +import io.rsocket.kotlin.RSocketRequestHandler +import io.rsocket.kotlin.ktor.server.rSocket + +/** + * Creates and configures an RSocket server with the specified endpoint and RSocketProtoServer. + * + * @param endpoint The endpoint to bind the server to. Default value is `/rrpc`. + * @param module The module that will be handling the incoming requests. + */ +public fun Routing.rrpcEndpoint(endpoint: String = "/rrpc", module: RRpcModule) { + rSocket(endpoint) { + RSocketRequestHandler { + RRpcModuleHandler(module).setup(this) + } + } +} + +/** + * Creates an RSocket server endpoint on the specified routing path. + * + * @param endpoint The routing path for the RSocket server. + * @param block The configuration block for the RRpcModuleBuilder. + */ +public fun Routing.rrpcEndpoint(endpoint: String = "/rrpc", block: RRpcModuleBuilder.() -> Unit) { + rrpcEndpoint(endpoint, RRpcModuleBuilder().apply(block).build()) +} diff --git a/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/descriptors/ProcedureDescriptor.kt b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/descriptors/ProcedureDescriptor.kt new file mode 100644 index 0000000..5e612f1 --- /dev/null +++ b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/descriptors/ProcedureDescriptor.kt @@ -0,0 +1,142 @@ +package org.timemates.rrpc.server.module.descriptors + +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import org.timemates.rrpc.options.OptionsWithValue +import org.timemates.rrpc.server.OptionsContainer +import org.timemates.rrpc.server.RequestContext +import org.timemates.rrpc.server.optionsContainer + +/** + * A sealed interface representing a procedure descriptor within a service. + * Contains common properties for different types of procedures. + */ +public sealed interface ProcedureDescriptor : OptionsContainer { + /** + * The name of the procedure. + */ + public val name: String + + /** + * A data class representing a request-response type procedure descriptor. + * + * @param name The name of the procedure. + * @param inputSerializer The deserialization strategy for the input of the procedure. + * @param outputSerializer The serialization strategy for the output of the procedure. + * @param procedure The suspend function representing the procedure logic. + * @param options The list of options associated with the procedure. + */ + public class RequestResponse( + override val name: String, + public val inputSerializer: DeserializationStrategy, + public val outputSerializer: SerializationStrategy, + private val procedure: suspend (RequestContext, TInput) -> TOutput, + options: OptionsWithValue, + ) : ProcedureDescriptor, OptionsContainer by optionsContainer(options) { + /** + * Executes the request-response procedure. + * @return The response payload. + */ + public suspend fun execute( + context: RequestContext, + input: TInput, + ): TOutput { + return procedure(context, input) + } + } + + /** + * A data class representing a request-stream type procedure descriptor. + * + * @param name The name of the procedure. + * @param inputSerializer The deserialization strategy for the input of the procedure. + * @param outputSerializer The serialization strategy for the output of the procedure. + * @param procedure The suspend function representing the procedure logic. + * @param options The list of options associated with the procedure. + */ + public class RequestStream( + override val name: String, + public val inputSerializer: DeserializationStrategy, + public val outputSerializer: SerializationStrategy, + private val procedure: suspend (RequestContext, TInput) -> Flow, + options: OptionsWithValue, + ) : ProcedureDescriptor, OptionsContainer by optionsContainer(options) { + /** + * Executes the request-stream procedure. + * @return A flow of response payloads. + */ + public suspend fun execute( + context: RequestContext, + value: TInput, + ): Flow { + return procedure(context, value) + } + } + + /** + * A data class representing a request-channel type procedure descriptor. + * + * @param name The name of the procedure. + * @param inputSerializer The deserialization strategy for the input of the procedure. + * @param outputSerializer The serialization strategy for the output of the procedure. + * @param procedure The suspend function representing the procedure logic. + * @param options The list of options associated with the procedure. + */ + public class RequestChannel( + override val name: String, + public val inputSerializer: DeserializationStrategy, + public val outputSerializer: SerializationStrategy, + private val procedure: suspend (RequestContext, Flow) -> Flow, + options: OptionsWithValue, + ) : ProcedureDescriptor, OptionsContainer by optionsContainer(options) { + /** + * Executes the request-channel procedure. + * + * @param protoBuf The ProtoBuf instance to use for serialization and deserialization. + * @param init The initial packet containing the serialized input data. + * @param incoming The flow of incoming request packets. + * @return A flow of response payloads. + */ + public suspend fun execute( + context: RequestContext, + flow: Flow, + ): Flow { + return procedure(context, flow) + } + } + + /** + * Represents a Fire-And-Forget request type in RSocket. + */ + public class FireAndForget( + override val name: String, + public val inputSerializer: DeserializationStrategy, + private val procedure: suspend (RequestContext, TInput) -> Unit, + options: OptionsWithValue, + ) : ProcedureDescriptor, OptionsContainer by optionsContainer(options) { + + public suspend fun execute( + context: RequestContext, + input: TInput, + ) { + return procedure(context, input) + } + } + + /** + * Represents Metadata-Push request in RSocket. + */ + public class MetadataPush( + override val name: String, + private val procedure: suspend (RequestContext) -> Unit, + options: OptionsWithValue, + ) : ProcedureDescriptor, OptionsContainer by optionsContainer(options) { + + public suspend fun execute( + context: RequestContext, + ) { + return procedure(context) + } + } +} \ No newline at end of file diff --git a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/descriptors/ServiceDescriptor.kt b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/descriptors/ServiceDescriptor.kt similarity index 70% rename from server-core/src/commonMain/kotlin/org/timemates/rsproto/server/descriptors/ServiceDescriptor.kt rename to server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/descriptors/ServiceDescriptor.kt index 3b7fbc0..93d2f32 100644 --- a/server-core/src/commonMain/kotlin/org/timemates/rsproto/server/descriptors/ServiceDescriptor.kt +++ b/server/core/src/commonMain/kotlin/org/timemates/rrpc/server/module/descriptors/ServiceDescriptor.kt @@ -1,19 +1,22 @@ -package org.timemates.rsproto.server.descriptors +package org.timemates.rrpc.server.module.descriptors +import org.timemates.rrpc.server.OptionsContainer +import org.timemates.rrpc.options.OptionsWithValue +import org.timemates.rrpc.server.optionsContainer import kotlin.reflect.KClass - /** - * A data class representing a service descriptor. + * Represents a service descriptor containing the service name and a list of procedure descriptors. * * @property name The name of the service. - * @property procedures The list of procedure descriptors for the service. - * @property proceduresMap A map of procedure names and their corresponding descriptors. + * @property procedures The list of procedure descriptors associated with the service. */ -public data class ServiceDescriptor( +@Suppress("UNCHECKED_CAST") +public class ServiceDescriptor( public val name: String, public val procedures: List, -) { + options: OptionsWithValue, +) : OptionsContainer by optionsContainer(options) { /** * A map that associates procedure names with their corresponding descriptors. */ diff --git a/server/schema/README.md b/server/schema/README.md new file mode 100644 index 0000000..9a17ec5 --- /dev/null +++ b/server/schema/README.md @@ -0,0 +1,5 @@ +# Server Schema Client +This module is used to provide rich information about Services, Types and so on that +is running on the server. + +To communicate from the client side, use [client-schema](../../client/schema). \ No newline at end of file diff --git a/server/schema/build.gradle.kts b/server/schema/build.gradle.kts new file mode 100644 index 0000000..3a3b72f --- /dev/null +++ b/server/schema/build.gradle.kts @@ -0,0 +1,34 @@ +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.server.core) + commonMainImplementation(projects.common.schema) + + // -- Ktor -- + commonMainImplementation(libs.ktor.server.core) + commonMainImplementation(libs.ktor.server.websockets) + + // -- Serialization -- + commonMainImplementation(libs.kotlinx.serialization.proto) +} + +mavenPublishing { + coordinates( + groupId = "org.timemates.rrpc", + artifactId = "server-metadata", + version = System.getenv("LIB_VERSION") ?: return@mavenPublishing, + ) + + pom { + name.set("RRpc Server Metadata") + description.set("Multiplatform Kotlin metadata library for RRpc servers.") + } +} diff --git a/server/schema/src/commonMain/kotlin/org/timemates/rrpc/server/schema/RRpcModuleBuilder.kt b/server/schema/src/commonMain/kotlin/org/timemates/rrpc/server/schema/RRpcModuleBuilder.kt new file mode 100644 index 0000000..e365c55 --- /dev/null +++ b/server/schema/src/commonMain/kotlin/org/timemates/rrpc/server/schema/RRpcModuleBuilder.kt @@ -0,0 +1,13 @@ +package org.timemates.rrpc.server.schema + +import org.timemates.rrpc.server.module.RRpcModuleBuilder + +/** + * Registers a [SchemaService] for given module. By default, it will use + * global instance of [SchemaLookup]. + */ +public fun RRpcModuleBuilder.ServicesBuilder.schemaService( + lookup: SchemaLookup = SchemaLookup.Global, +) { + register(SchemaService(lookup)) +} \ No newline at end of file diff --git a/server/schema/src/commonMain/kotlin/org/timemates/rrpc/server/schema/SchemaMetadata.kt b/server/schema/src/commonMain/kotlin/org/timemates/rrpc/server/schema/SchemaMetadata.kt new file mode 100644 index 0000000..94c9ad8 --- /dev/null +++ b/server/schema/src/commonMain/kotlin/org/timemates/rrpc/server/schema/SchemaMetadata.kt @@ -0,0 +1,25 @@ +package org.timemates.rrpc.server.schema + +import org.timemates.rrpc.common.schema.RSResolver + +public fun SchemaMetadata(resolver: RSResolver): SchemaMetadata = DelegatedSchemaMetadataGroup(resolver) + +public interface SchemaMetadata : RSResolver { + public companion object Global : SchemaMetadata by _resolver { + /** + * Register a [SchemaMetadata] to the global scope that is used by default. + */ + public fun register(group: SchemaMetadata) { + _resolver = DelegatedSchemaMetadataGroup(RSResolver(this, group)) + } + + /** + * Register a [RSResolver] to the global scope that is used by default. + */ + public fun register(resolver: RSResolver): Unit = register(SchemaMetadata(resolver)) + } +} + +internal class DelegatedSchemaMetadataGroup(resolver: RSResolver) : SchemaMetadata, RSResolver by _resolver + +private var _resolver: SchemaMetadata = DelegatedSchemaMetadataGroup(RSResolver(emptyList())) diff --git a/server/schema/src/commonMain/kotlin/org/timemates/rrpc/server/schema/SchemaService.kt b/server/schema/src/commonMain/kotlin/org/timemates/rrpc/server/schema/SchemaService.kt new file mode 100644 index 0000000..14b5382 --- /dev/null +++ b/server/schema/src/commonMain/kotlin/org/timemates/rrpc/server/schema/SchemaService.kt @@ -0,0 +1,108 @@ +package org.timemates.rrpc.server.schema + +import io.rsocket.kotlin.RSocketError +import org.timemates.rrpc.common.schema.RSFile +import org.timemates.rrpc.common.schema.RSService +import org.timemates.rrpc.common.schema.RSType +import org.timemates.rrpc.options.OptionsWithValue +import org.timemates.rrpc.server.module.RRpcService +import org.timemates.rrpc.server.module.descriptors.ProcedureDescriptor +import org.timemates.rrpc.server.module.descriptors.ServiceDescriptor +import org.timemates.rrpc.server.schema.request.BatchedRequest +import org.timemates.rrpc.server.schema.request.PagedRequest +import org.timemates.rrpc.server.schema.request.decoded +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Handles metadata-related operations for the RSocket-based reflection service. + * This service exposes APIs that allow clients to query information about + * available services, types, and extensions in the system. + * + * It supports both paged requests for larger datasets and batched requests + * for retrieving multiple types of metadata in one go. + * + * @param group The metadata lookup group used for resolving services, types, and extensions. + */ +public class SchemaService( + private val group: SchemaMetadata = SchemaMetadata.Global, +) : RRpcService { + override val descriptor: ServiceDescriptor = ServiceDescriptor( + name = "timemates.rrpc.server.schema.SchemaService", + procedures = listOf( + ProcedureDescriptor.RequestResponse( + name = "GetAvailableServices", + inputSerializer = PagedRequest.serializer(), + outputSerializer = PagedRequest.Response.serializer(RSService.serializer()), + procedure = { _, request -> getAvailableServices(request) }, + options = OptionsWithValue.EMPTY, + ), + ProcedureDescriptor.RequestResponse( + name = "GetAvailableFiles", + inputSerializer = PagedRequest.serializer(), + outputSerializer = PagedRequest.Response.serializer(RSFile.serializer()), + procedure = { _, request -> getAvailableFiles(request) }, + options = OptionsWithValue.EMPTY, + ), + ProcedureDescriptor.RequestResponse( + name = "GetTypeDetailsBatch", + inputSerializer = BatchedRequest.serializer(), + outputSerializer = BatchedRequest.Response.serializer(RSType.serializer()), + procedure = { _, request -> getTypeDetailsBatch(request) }, + options = OptionsWithValue.EMPTY, + ), + ), + options = OptionsWithValue.EMPTY, + ) + + /** + * Retrieves a paginated list of available services from the metadata. + * + * @param request The paged request containing the pagination token and size. + * @return A paginated response containing a list of RMService objects. + */ + public fun getAvailableServices(request: PagedRequest): PagedRequest.Response { + val decoded = request.decoded()?.split(":") + val index = decoded?.getOrElse(1) { invalidPageToken() }?.toInt() ?: -1 + + val result = group.resolveAllServices().drop(index + 1).take(request.size ?: 20).toList() + + return PagedRequest.Response.encoded( + list = result, + nextCursor = if (result.size == request.size) "c:${index + result.size}" else null + ) + } + + /** + * Retrieves available files that are loaded into [SchemaMetadata]. + * + * @param request The paged request containing pagination information. + * @return A paged response containing a list of available RMFiles. + */ + @OptIn(ExperimentalEncodingApi::class) + public fun getAvailableFiles(request: PagedRequest): PagedRequest.Response { + val decoded = request.decoded()?.split(":") + val index = decoded?.getOrElse(1) { invalidPageToken() }?.toInt() ?: -1 + + val result = group.resolveAvailableFiles().drop(index + 1).take(30).toList() + + return PagedRequest.Response.encoded( + list = result, + nextCursor = if (result.size == index) "c:${index + result.size}" else null, + ) + } + + /** + * Retrieves type details based on a batch of requested URLs. + * + * Each RMDeclarationUrl is mapped to either the found RMType or null if no type was found. + * + * @param request The batched request containing a list of RMDeclarationUrl objects. + * @return A response containing a map of RMDeclarationUrl to RMType (or null if not found). + */ + public fun getTypeDetailsBatch(request: BatchedRequest): BatchedRequest.Response { + val types = request.urls.associateWith { group.resolveType(it) } + return BatchedRequest.Response(types) + } +} + +private fun invalidPageToken(): Nothing = throw RSocketError.Invalid("Invalid page token") \ No newline at end of file diff --git a/server/schema/src/commonMain/kotlin/org/timemates/rrpc/server/schema/request/BatchedRequest.kt b/server/schema/src/commonMain/kotlin/org/timemates/rrpc/server/schema/request/BatchedRequest.kt new file mode 100644 index 0000000..3ef7de8 --- /dev/null +++ b/server/schema/src/commonMain/kotlin/org/timemates/rrpc/server/schema/request/BatchedRequest.kt @@ -0,0 +1,22 @@ +package org.timemates.rrpc.server.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/server/schema/src/commonMain/kotlin/org/timemates/rrpc/server/schema/request/PagedRequest.kt b/server/schema/src/commonMain/kotlin/org/timemates/rrpc/server/schema/request/PagedRequest.kt new file mode 100644 index 0000000..f146d3c --- /dev/null +++ b/server/schema/src/commonMain/kotlin/org/timemates/rrpc/server/schema/request/PagedRequest.kt @@ -0,0 +1,40 @@ +package org.timemates.rrpc.server.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, +) { + /** + * 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?, + ) { + public companion object { + @OptIn(ExperimentalEncodingApi::class) + internal fun encoded(list: List, nextCursor: String?): Response { + return Response(list, nextCursor?.let { Base64.encode(it.toByteArray()) }) + } + } + } +} + +@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/settings.gradle.kts b/settings.gradle.kts index 599af62..177e473 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,10 +19,32 @@ dependencyResolutionManagement { } } -rootProject.name = "rsproto" +rootProject.name = "rrpc" includeBuild("build-conventions") -include(":common-core", ":server-core", ":client-core") -include(":code-generator") -include(":gradle-plugin") +include( + ":common:core", + ":common:schema", + ":common:core:java-support", +) + +include( + ":server:core", + ":server:schema", +) + +include( + ":client:core", + ":client:schema", +) + +include(":integration-tests") + +include( + ":tooling:rrpc-testing-app", +) + +//include( +// ":internal:dynamic-serialization" +//) diff --git a/tooling/rrpc-testing-app/build.gradle.kts b/tooling/rrpc-testing-app/build.gradle.kts new file mode 100644 index 0000000..1776561 --- /dev/null +++ b/tooling/rrpc-testing-app/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + id(libs.plugins.conventions.multiplatform.core.get().pluginId) + alias(libs.plugins.jetbrains.compose) + alias(libs.plugins.jetbrains.compiler.compose) + application + alias(libs.plugins.graalvm.native) +} + +dependencies { + // -- Compose -- + commonMainImplementation(compose.ui) + commonMainImplementation(compose.material3) + commonMainImplementation(compose.materialIconsExtended) + + // -- Decompose -- + commonMainImplementation(libs.decompose) + commonMainImplementation(libs.decompose.jetbrains.compose) + + // -- FlowMVI -- + commonMainImplementation(libs.flowmvi.core) + commonMainImplementation(libs.flowmvi.compose) + commonMainImplementation(libs.flowmvi.essenty.compose) + + // -- SquareUp -- + commonMainImplementation(libs.squareup.okio) + + // -- Schema -- + commonMainImplementation(projects.common.schema) + commonMainImplementation(projects.client.schema) + + // -- Bonsai (Compose Tree) -- + commonMainImplementation(libs.bonsai.core) +} \ No newline at end of file diff --git a/tooling/rrpcurl/src/commonMain/kotlin/org/timemates/rrpc/app/RRpcDebugApp.kt b/tooling/rrpcurl/src/commonMain/kotlin/org/timemates/rrpc/app/RRpcDebugApp.kt new file mode 100644 index 0000000..ea9793d --- /dev/null +++ b/tooling/rrpcurl/src/commonMain/kotlin/org/timemates/rrpc/app/RRpcDebugApp.kt @@ -0,0 +1,20 @@ +package org.timemates.rrpc.app + +import androidx.compose.ui.Alignment +import androidx.compose.ui.window.* +import org.timemates.rrpc.app.resource.strings.LocalStrings + +fun main(): Unit = application { + val windowState = rememberWindowState( + placement = WindowPlacement.Floating, + position = WindowPosition.Aligned(Alignment.Center), + ) + + Window( + title = LocalStrings.current.appName, + state = windowState, + onCloseRequest = ::exitApplication, + ) { + + } +} \ No newline at end of file diff --git a/tooling/rrpcurl/src/commonMain/kotlin/org/timemates/rrpc/app/resource/strings/EnglishStrings.kt b/tooling/rrpcurl/src/commonMain/kotlin/org/timemates/rrpc/app/resource/strings/EnglishStrings.kt new file mode 100644 index 0000000..dc03264 --- /dev/null +++ b/tooling/rrpcurl/src/commonMain/kotlin/org/timemates/rrpc/app/resource/strings/EnglishStrings.kt @@ -0,0 +1,5 @@ +package org.timemates.rrpc.app.resource.strings + +object EnglishStrings : Strings { + +} \ No newline at end of file diff --git a/tooling/rrpcurl/src/commonMain/kotlin/org/timemates/rrpc/app/resource/strings/Strings.kt b/tooling/rrpcurl/src/commonMain/kotlin/org/timemates/rrpc/app/resource/strings/Strings.kt new file mode 100644 index 0000000..7605a18 --- /dev/null +++ b/tooling/rrpcurl/src/commonMain/kotlin/org/timemates/rrpc/app/resource/strings/Strings.kt @@ -0,0 +1,12 @@ +package org.timemates.rrpc.app.resource.strings + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf + +interface Strings { + val appName: String get() = "rRPC Inspector" +} + +val LocalStrings: ProvidableCompositionLocal = compositionLocalOf { + EnglishStrings +} \ No newline at end of file