Skip to content

Kotlin Multiplatform library for shared type-safe HTTP APIs with Ktor

License

Notifications You must be signed in to change notification settings

felixwiemuth/TypesafeKtorAPI

Repository files navigation

Typesafe Ktor API (PoC)

A Kotlin Multiplatform library for enabling type-safe HTTP APIs with code shared between server and clients.

Status: Proof of concept, work-in-progress

Table of contents

About

Building on top of Ktor's Resource classes for type-safe requests with Ktor client and type-safe routing for Ktor server, this library adds the remaining bits for fully type-safe requests and responses which adhere to a commonly defined API, for which the code can be shared between server and different clients.

Features

  • HTTP API which can be used from any client, also without this library or Ktor
  • Free choice of API structure
    • Custom endpoint hierarchy with HTTP methods like POST, GET, PUT, DELETE
    • Optional path- and query parameters
    • Supports verb- and noun-centric approaches
  • RPC-style: an API call is just a call to a suspend function
    • Together with Ktor, the library takes care of serialization, sending/receiving and structured result-/error-handling
  • Fully type-safe: client can only send requests adhering to the API and server can only send responses corresponding to the request
    • Custom request, response and error types - any serializable Kotlin class can be used
  • API-definition via pure Kotlin code
    • Structured overview and documentation without any extra tools
    • Code-completion in IDEs
    • Easy refactoring just as any other code
  • Shared code
    • Use the same API-definition at server to implement requests and at client to make requests
    • Multiplatform: supports same platforms as Ktor and enables code-sharing
    • Elimination of errors: No more bugs due to inconsistent updates at client and server - changes apply everywhere directly and type-safety ensures that all parts are updated accordingly
  • Code generation for (the little amount of) glue
  • Building on top of Ktor, using the existing features and leaving flexibility in configuring client and server
  • JSON serialization (or any other format supported by Ktor / kotlinx.serialization)

Use cases

  • Accelerate development by reducing redundancy and making changes as simple as IDE-supported refactoring:
    • One shared API definition accessible by server and all clients on different platforms
    • Elimination of whole class of errors due to type-safety and sharing of the same code
  • Model an external API to allow type-safe usage on different clients

Overview

Find the whole example in the modules tka-example-api, tka-example-client and tka-example-server.

Defining the API

Define serializable classes for request/response parameters

@Serializable
data class Order(
    val orderId: Long,
    val customerId: Long,
    val items: List<OrderItem>,
    val totalAmount: Int
)

Define requests and routes

The API is defined via a hierarchy of Resource classes (as known from Ktor) plus adding Get/Post interfaces under each endpoint. These interfaces must extend the library's GET/POST interfaces, specifying request type, result type, error type and parameter type (only for POST).

@Resource("/orders")
class OrdersApi {

    @Resource("list")
    class ListOrders(val p: OrdersApi = OrdersApi(), val userId: Int?, val categoryId: Int?) {
        // Result type: List<Order>, Error type: Unit
        interface Get : GET<ListOrders, List<Order>, Unit>
    }

    @Resource("new")
    class New(val p: OrdersApi = OrdersApi()) {
        // Parameter type: Order, Result type: Int, Error type: Err
        interface Post : POST<New, Order, Int, Post.Err> {
            sealed class Err {
                class OrderWithIdAlreadyExists(val id: Int) : Err()
                data object NoItems : Err()
            }
        }
    }
}

Implementing the API server-side

Defining the logic for each request

Implementing the logic simply consists of implementing the Get and Post interfaces from the API definition.

object OrdersController {
    object ListOrders : OrdersApi.ListOrders.Get {
        override suspend fun OrdersApi.ListOrders.get(): ApiResponse<List<Order>, Unit> =
            // We have direct access to the orders parameters
            // "ok" is a shortcut for replying with ApiResponse.Ok
            ok(OrdersRepository.orders.filter { it.customerId == customerId && it.items.any { it.categoryId == categoryId } })
    }

    object New : OrdersApi.New.Post {
        override suspend fun OrdersApi.New.post(param: Order): ApiResponse<Long, OrdersApi.New.Post.Err> =
            when {
                OrdersRepository.orders.any { it.orderId == param.orderId } ->
                    err(OrdersApi.New.Post.Err.OrderWithIdAlreadyExists(param.orderId))
                param.items.isEmpty() ->
                    err(OrdersApi.New.Post.Err.NoItems)
                else -> ok(param.orderId)
            }
    }
}

Registering the routes

First define an extension function on io.ktor.server.routing.Routing which adds routes for each Resource class using io.ktor.server.resources.get etc. by delegating to the implemented get and post functions. There will be an annotation generating this glue in the future.

fun Routing.ordersRoutes() {
    get<OrdersApi.ListOrders> {
        reply(it.get())
    }

    post<OrdersApi.New> {
        reply(it.post(call.receive()))
    }
}

Finally, the routes must be registered with the Ktor server:

embeddedServer(Netty, 3000) {
    install(ContentNegotiation) {
        json()
    }
    install(Resources)
    install(CORS) {
        allowMethod(HttpMethod.Get)
        allowMethod(HttpMethod.Post)
        allowMethod(HttpMethod.Delete)
        anyHost()
        allowHeader(HttpHeaders.ContentType)
        allowCredentials = true
        allowNonSimpleContentTypes = true
    }
    routing {
        ordersRoutes()
    }
}.start(wait = true)

Using the API from a client

Registering APIs at the client

The @WithApis generates glue for the API's Resource classes to send requests using the annotated client.

@WithApis(apis = [OrdersApi::class])
val client = HttpClient {
    install(ContentNegotiation) {
        json()
    }
    install(Resources)
    ...
}

Making requests

Requests can then be made by calling get and post on the Resource classes.

// HTTP GET: /orders/list?userId=42?categoryId=10
val orders = OrdersApi
    .ListOrders(userId = 42, categoryId = 10)
    .get() // The @WithApis annotation generates this function which sends the request using client
    .result() // Get the result or throw an exception if an error is returned
orders.forEach { println("Order ${it.orderId} from customer ${it.customerId}") }

// HTTP POST: /orders/new
when (val r = OrdersApi
    .New()
    .post(Order(orderId = 573, customerId = 123, items = emptyList(), totalAmount = 0))
) {
    is ApiResponse.Ok -> println("Order created with id ${r.result}")
    is ApiResponse.RequestErr -> when (val e = r.err) {
        OrdersApi.New.Post.Err.NoItems -> println("Could not create order: the order contains no items")
        // Note that we get type-safe access to the error message's parameters!
        is OrdersApi.New.Post.Err.OrderWithIdAlreadyExists -> println("An order with id ${e.id} already exists")
    }
    is ApiResponse.Err -> println("Error sending or receiving")
}

Setup and Usage

Adding the library to the project

Because of being in a very early stage, the library is currently not published in any repository and has to be added manually to a project, for example as a git submodule:

git submodule add https://github.com/felixwiemuth/TypesafeKtorAPI

Then register the modules in your project's settings.gradle (assuming that the library is cloned to a subdirectory TypesafeKtorAPI of the project):

include("TypesafeKtorAPI:tka-base")
include("TypesafeKtorAPI:tka-server")
include("TypesafeKtorAPI:tka-client")
include("TypesafeKtorAPI:tka-client-plugin")

Project structure and dependencies

A project using this library would usually introduce a separate API-module in addition to a server- and several client-modules.

The purpose and dependencies for the different modules are as follows:

Module Purpose Dependencies
API API definition and data classes to be shared
between server and different clients
  • io.ktor:ktor-resources
  • tka-base
Server Implementation of the API
  • io.ktor:ktor-server-cors
  • io.ktor:ktor-server-content-negotiation
  • io.ktor:ktor-server-serialization-kotlinx.json
  • io.ktor:ktor-server-resources
  • tka-base
  • tka-server
  • your-api-module
Client Usage of the API
  • io.ktor:ktor-client-core
  • io.ktor:ktor-client-content-negotiation
  • io.ktor:ktor-client-serialization-kotlinx.json
  • io.ktor:ktor-client-resources
  • tka-base
  • tka-client
  • tka-client-plugin (optional)
  • your-api-module

The TKA-dependencies can be added to the modules via implementation("TypesafeKtorAPI:tka-base") etc.

Note that for Kotlin Multiplatform projects, the tka-client-plugin dependency has to be added differently, namely via the add function in the top-level dependencies section. See the official documentation for further information.

About

Kotlin Multiplatform library for shared type-safe HTTP APIs with Ktor

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages