A Kotlin Multiplatform library for enabling type-safe HTTP APIs with code shared between server and clients.
Status: Proof of concept, work-in-progress
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.
- 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)
- 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
Find the whole example in the modules tka-example-api, tka-example-client and tka-example-server.
@Serializable
data class Order(
val orderId: Long,
val customerId: Long,
val items: List<OrderItem>,
val totalAmount: Int
)
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 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)
}
}
}
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)
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)
...
}
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")
}
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")
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 |
|
Server | Implementation of the API |
|
Client | Usage of the API |
|
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.