[Feedback request] kotlin-rpc, a Kotlin-centric multiplatform RPC library

Hi all,

I came up with an idea for a convenient and safe remote procedure calling for Kotlin multiplatform. I’d like to share a PoC of a library and gather some feedback.

Description

It’s an RPC solution optimized for a case where we use 100% Kotlin (both client and server). Such assumption allows for some extra benefits described below. This approach is also optimized for cases where what goes “over the wire” is not that important and can remain an implementation detail. The goal is to type as little as possible, and have as much build-time safety as possible. So far I tested it with quickly implementing PoCs of two very simple services and I also noticed the speed of iteration is pretty nice.

Library: GitHub - krzema12/kotlin-rpc: Kotlin-centric, multiplatform-enabled approach to RPC.
Example: GitHub - krzema12/kotlin-rpc-example: Example for https://github.com/krzema12/kotlin-rpc

Features:
:star: the service definition is described as a Kotlin interface. No new DSL like in gRPC or OpenAPI. It eliminates a need to learn another language, enables IDE support out of the box, allows reusing data types from Kotlin multiplatform libraries, and more
:star: designed to provide build-time safety. Built on top of kotlinx.serialization used the right way, it won’t let the project compile in case some data types are not @Serializable
:star: boilerplate-free. Uses code generation to hide all the repetitive stuff, yet the code is readable and inspectable so it’s clear what happens under the hood (no compiler plugin intentionally)
:star: simple. Not super-generic and uber-customizable. Focuses to get your call from one side to the other, with minimal effort
:star: non-blocking. Promotes coroutines by enforcing suspending functions in the interface.
:star: (in the future) designed to provide clients for JS, Android, iOS and any other platform Kotlin allows. I also plan to support a use case where the medium is not HTTP, but Web workers: calling the worker from the main thread. For now, only the “server -JVM, client - JS” scenario is implemented

Areas of improvement:
:warning: integration is not super-easy. I tried to encapsulate it in a Gradle plugin, but failed to do so within reasonable time. I decided to ask for feedback even if integration is not simple. The task for the Gradle plugin is tracked as Gradle plugin · Issue #13 · krzema12/kotlin-rpc · GitHub
:warning: there’s a known problem with the moment where code generation occurs. Currently to overcome it, the service definition needs to be placed in a separate Gradle module and it has to be built explicitly first. I think I understand why it happens, but I don’t have a clear path how to fix it. Tracked as First building of API module doesn't compile generated code · Issue #7 · krzema12/kotlin-rpc · GitHub
:warning: exception serialization is buggy, see Problem with exception serialization with coroutines · Issue #29 · krzema12/kotlin-rpc · GitHub
:warning: lacks possibility to customize the client, like authentication, retry strategy…

…and probably many more. Definitely not ready to use in production, but ready to give it a try to get the overall look and feel.

Example with walk-through

Click to show

Based on GitHub - krzema12/kotlin-rpc-example: Example for https://github.com/krzema12/kotlin-rpc.

First, let’s see the project’s structure - it’s actually pretty simple. Omitting not important files, we have just 3 Kotlin files (API, server, and client) plus build logic that is also relevant here:

$ tree
.
├── api
│   ├── build.gradle.kts
│   └── src
│       └── commonMain
│           └── kotlin
│               └── TodoAppApi.kt
├── build.gradle.kts
└── src
    ├── jsMain
    │   └── kotlin
    │       └── client.kt
    └── jvmMain
        └── kotlin
            └── server.kt

I’m omitting description of configuring the build system because it will likely become much simpler, and it’s already described in the README of GitHub - krzema12/kotlin-rpc: Kotlin-centric, multiplatform-enabled approach to RPC..

We start with defining the service interface (TodoAppApi.kt). It contains an interface with two methods that are forced to be suspending, to promote non-blocking calls:

interface TodoAppApi {
    suspend fun listTodos(): List<Todo>
    suspend fun addToList(description: String): List<Todo>
}

Todo has to be marked as @Serializable, otherwise generating the code for this interface (either client or server) will fail and will make the api module building fail as well:

@Serializable
data class Todo(
    val description: String,
    val isDone: Boolean,
    val assignee: String? = null,
)

The structures can have arbitrary depth, as long as they’re serializable in the understanding of kotlinx.serialization.

Now, to to implement the server part, code generation provides us with such Ktor handlers, ready to be placed in routing DSL part of Ktor. URL pieces are generated based on method names in the interface. POST is used for simplicity:

fun Routing.todoAppApiKtorHandlers(todoAppApiImpl: it.krzeminski.todoapp.api.TodoAppApi) {
    route("api/") {

        post("/addToList") {
            val bodyAsString = call.receiveText()
            val body = Json.decodeFromString<AddToListRequest>(bodyAsString)

            try {
                val implResponse = todoAppApiImpl.addToList(
                    description = body.description,
                )
                val implResponseWrapped = AddToListResponse(returnValue = implResponse)
                val kotlinRpcResponse = KotlinRpcResponse(body = Json.encodeToString(AddToListResponse.serializer(), implResponseWrapped))

                call.respond(HttpStatusCode.OK, Json.encodeToString(kotlinRpcResponse))
            } catch (e: Exception) {
                val kotlinRpcResponse = KotlinRpcResponse(exception = e.message)
                call.respond(HttpStatusCode.InternalServerError, Json.encodeToString(kotlinRpcResponse))
            }
        }

        post("/listTodos") {

            try {
                val implResponse = todoAppApiImpl.listTodos()
                val implResponseWrapped = ListTodosResponse(returnValue = implResponse)
                val kotlinRpcResponse = KotlinRpcResponse(body = Json.encodeToString(ListTodosResponse.serializer(), implResponseWrapped))

                call.respond(HttpStatusCode.OK, Json.encodeToString(kotlinRpcResponse))
            } catch (e: Exception) {
                val kotlinRpcResponse = KotlinRpcResponse(exception = e.message)
                call.respond(HttpStatusCode.InternalServerError, Json.encodeToString(kotlinRpcResponse))
            }
        }
    }
}

@Serializable
data class AddToListRequest(
    val description: kotlin.String,
)

@Serializable
data class AddToListResponse(
    val returnValue: kotlin.collections.List<it.krzeminski.todoapp.api.Todo>,
)

@Serializable
data class ListTodosResponse(
    val returnValue: kotlin.collections.List<it.krzeminski.todoapp.api.Todo>,
)

and it’s used from within Ktor DSL like this (server.kt):

    embeddedServer(Netty, port = 8080, host = "127.0.0.1") {
        routing {
            todoAppApiKtorHandlers(todoAppApiImpl) // Function generated by kotlin-rpc
        }
    }.start(wait = true)

where todoAppApiImpl just implements the interface defined at the very beginning, as if there was no network calls. The whole burden of serialization and deserialization is hidden in todoAppApiKtorHandlers. What’s exposed to the user is the very minimum.

From the JS client’s side (client.kt), e.g. fetching TODOs is as simple as creating an instance of the client generated by kotlin-rpc:

class TodoAppApiJsClient(private val url: String, private val coroutineContext: CoroutineContext) : it.krzeminski.todoapp.api.TodoAppApi {

    @Serializable
    data class AddToListRequest(
        val description: kotlin.String,
    )

    @Serializable
    data class AddToListResponse(
        val returnValue: kotlin.collections.List<it.krzeminski.todoapp.api.Todo>,
    )

    override suspend fun addToList(
        description: kotlin.String,
    ): kotlin.collections.List<it.krzeminski.todoapp.api.Todo> {
        val requestBody = AddToListRequest(
            description,
        )
        val requestBodyAsString = Json.encodeToString(AddToListRequest.serializer(), requestBody)

        val responseAsString = post("$url/api/addToList", requestBodyAsString)
        val response = Json.decodeFromString<KotlinRpcResponse>(responseAsString)

        if (response.exception != null) {
            // For now, only top-level message is serialized.
            val exceptionMessage = response.exception
            throw RuntimeException(exceptionMessage)
        }

        val responseBody = Json.decodeFromString<AddToListResponse>(response.body ?: throw RuntimeException("Shouldn't happen - exception or body should be present!"))
        return responseBody.returnValue
    }

    @Serializable
    data class ListTodosResponse(
        val returnValue: kotlin.collections.List<it.krzeminski.todoapp.api.Todo>,
    )

    override suspend fun listTodos(): kotlin.collections.List<it.krzeminski.todoapp.api.Todo> {

        val requestBodyAsString = ""

        val responseAsString = post("$url/api/listTodos", requestBodyAsString)
        val response = Json.decodeFromString<KotlinRpcResponse>(responseAsString)

        if (response.exception != null) {
            // For now, only top-level message is serialized.
            val exceptionMessage = response.exception
            throw RuntimeException(exceptionMessage)
        }

        val responseBody = Json.decodeFromString<ListTodosResponse>(response.body ?: throw RuntimeException("Shouldn't happen - exception or body should be present!"))
        return responseBody.returnValue
    }

    private suspend fun post(url: String, body: String): String {
        return withContext(coroutineContext) {
            val response = window.fetch(
                url,
                RequestInit(
                    "POST",
                    headers = json(
                        "Accept" to "application/json",
                        "Content-Type" to "application/json"
                    ),
                    body = body
                )
            ).await()

            response.text().await()
        }
    }
}

with providing the server endpoint, and calling desired function declared in the interface:

with(TodoAppApiJsClient(url = "http://localhost:8080", coroutineContext)) {
    launch {
        val fetchedTodos = listTodos()
        // do something with them
    }
}

Again, serialization of the request and deserialization of the response is put in the generated code.

Feedback request

Please let me know what you think about the general idea, if it’s worth working on further, and if you would be keen to give it a try in some evening project. I’m especially interested about comparing it with gRPC, OpenAPI or other similar solutions. I barely touched these libraries and I don’t have real-life experience with them. However, I needed something simple and compatible with Kotlin from both frontend and backend side, hence the idea for the library.

Thanks,
Piotr

Am I correct that no further development is made/planned for this project?

Since there’s near-zero interest regarding this library, and KVision’s mechanisms allow something similar (https://kvision.gitbook.io/kvision-guide/part-3-server-side-interface/overview), I indeed planned to archive the project. If you have a good reason not to do so, please let me know :slight_smile:

Fair enough…