Ktor Endpoints
One nice thing of using Ktor is that you can share some code between the server app, and the client -with KMP it can be a desktop, Android, iOS or a javascript app, or all of them!-. As much code you can share the better for two main reasons:
- Less code means less time to write it, mantian it, ...
- If the client and the server share code for the communication, you have a type safe communication.
When defining routes, Ktor resources plugin allows us to define routes and this routes can be used in both client and server. Thas nice! But, when using it I felt that that was not the best that could be done. I want to be able to define an endpoint of a json api with its:
- url
- http method (get, post,...)
- input: what class needs to be sent as the body of the request
- output: what class will be returned in the resposne (as a json).
Let's do it!
The sample app
To be original I'll create a TodoItems client-server app. You can download the finished sources here: https://github.com/mateuy-dev/ktor-endpoints-sample
Shared module
Defining the endpoint class
Firt we need a class to define endpoints. It needs to store the resource (url), method, input type and outputType. That is something generic (not deppending in the example).
package dev.trito.ktor.endpoints
import io.ktor.http.*
import io.ktor.util.reflect.*
/**
* Endpoint details: route (resource, input, output and httpMethod).
*/
class EndPoint<Route, Input, Output>(val httpMethod : HttpMethod, inputTypeInfo: TypeInfo){
val hasNoInput = inputTypeInfo.type == Unit::class
}
inline fun <Route, reified Input, reified Output> defineEndPoint(httpMethod : HttpMethod) : EndPoint<Route, Input, Output> {
val inputTypeInfo : TypeInfo = typeInfo<Input>()
return EndPoint(httpMethod, inputTypeInfo)
}
Defining the endpoints
Now we need to define our endpoints. If we have the following routes:
@Serializable
@Resource("/todoItems")
class TodoItemsRoute {
@Serializable
@Resource("new")
class New(val parent: TodoItemsRoute = TodoItemsRoute())
@Serializable
@Resource("{id}")
class Id(val parent: TodoItemsRoute = TodoItemsRoute(), val id: Long)
}
We will create three endpoints: list, view and create todo items.
object EndPoints{
object TodoItems {
val List = defineEndPoint<TodoItemsRoute, Unit, List<TodoItem>>(HttpMethod.Get)
val View = defineEndPoint<TodoItemsRoute.Id, String, TodoItem>(HttpMethod.Get)
val Create = defineEndPoint<TodoItemsRoute.New, TodoItemDataImp, TodoItem>(HttpMethod.Put)
}
}
Thats all we need on the shared module (I've skipped copying the model definitions, you can check them on the full project).
Server module
Now we need to define this endpoints on the server. Le'ts do it. We can define the routes with the following:
fun Application.todosRoutes() {
routing {
respondEndpoint(EndPoints.TodoItems.List){ _, _ ->
todoItemsRepository.list()
}
respondEndpoint(EndPoints.TodoItems.View){resource, _ ->
todoItemsRepository.find(resource.id)!!
}
respondEndpoint(EndPoints.TodoItems.Create) { _, data ->
todoItemsRepository.addItem(data)
}
}
}
Client Module
Now we need to call this endpoints from the client. I'll create a service module that makes the calls. Note that this services return a ServiceResult. A ServiceResult is either a Success with the output, or a failure with the error.
package dev.trito.endpoints.client
import dev.trito.endpoints.client.util.callEndpointForServiceResult
import dev.trito.endpoints.model.TodoItemDataImp
import dev.trito.endpoints.routes.EndPoints
import dev.trito.endpoints.routes.TodoItemsRoute
import io.ktor.client.*
class TodoItemsService(private val client : HttpClient = createClient()) {
suspend fun list() = client.callEndpointForServiceResult(EndPoints.TodoItems.List, TodoItemsRoute())
suspend fun create(todoItemData: TodoItemDataImp) = client.callEndpointForServiceResult(EndPoints.TodoItems.Create, TodoItemsRoute.New(), todoItemData)
suspend fun view(id: Long) = client.callEndpointForServiceResult(EndPoints.TodoItems.View, TodoItemsRoute.Id(id = id), "")
}
That's it.
Testing
One think that I like of using Ktor for server and client is that I can use the client for the server testing. That not be of the like of unit testing purists, but i just testst many things with very small efford:
@Test
fun `create adds an item that can be listed and viewed`() = todoItemsRoutesTest { service ->
val todoItem = randomTodoItem()
val result = service.create(todoItem)
assertTrue(result is ServiceResult.Success)
assertEquals(todoItem.title, result.data.title)
val listResult = service.list()
assertTrue(listResult is ServiceResult.Success)
assertTrue(listResult.data.any { it.title == todoItem.title })
val viewResult = service.view(result.data.id)
assertTrue(viewResult is ServiceResult.Success)
assertEquals(viewResult.data, result.data)
}
How all this works?
The "library" code is very small. You just need the Endpoint class that I've added on the top, one class on the server to help generating the routes, and one in the client to help making the calls.
Server helper
package dev.trito.ktor.endpoints
import dev.trito.ktor.endpoints.EndPoint
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.resources.*
import io.ktor.server.resources.handle
import io.ktor.server.routing.*
import io.ktor.util.pipeline.*
import kotlinx.serialization.serializer
/**
* Defines a route for the given endpoint
*/
inline fun <reified Resource : Any, reified Input : Any, reified Output: Any> Route.respondEndpoint(
endPoint: EndPoint<Resource, Input, Output>,
noinline body: suspend PipelineContext<Unit, ApplicationCall>.(Resource, Input) -> Output
): Route {
lateinit var builtRoute: Route
resource<Resource> {
builtRoute = method(endPoint.httpMethod) {
val serializer = serializer<Resource>()
handle (serializer){ resource ->
val input = if (endPoint.hasNoInput) Unit as Input else call.receive()
val result = body(resource, input)
call.respond(result)
}
}
}
return builtRoute
}
Client helper
Here you have how to make the calls:
package dev.trito.ktor.endpoints
import dev.trito.endpoints.client.util.asServiceResult
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.resources.*
import io.ktor.client.request.*
import io.ktor.http.*
/**
* Call the endpoint with the given input and returns the output as a ServiceResult
*/
suspend inline fun <reified Route : Any, reified Input, reified Output>
HttpClient.callEndpointForServiceResult(endPoint: EndPoint<Route, Input, Output>, route : Route, input : Input)
= asServiceResult<Output>{
callEndpoint(endPoint, route, input)
}
/**
* Call the endpoint that requires no input and returns the output as a ServiceResult
*/
suspend inline fun <reified Route : Any, reified Output>
HttpClient.callEndpointForServiceResult(endPoint: EndPoint<Route, Unit, Output>, route : Route)
= asServiceResult<Output>{
callEndpoint(endPoint, route, Unit)
}
/**
* Call the endpoint with the given input and returns the output.
* Will throw exception in case of response failure
*/
suspend inline fun <reified Route : Any, reified Input, reified Output>
HttpClient.callEndpointForResult(endPoint: EndPoint<Route, Input, Output>, route : Route, input : Input)
: Output =
callEndpoint(endPoint, route, input).body()
/**
* Calls the endpoint with the given input and returns the response
*/
suspend inline fun <reified Route : Any, reified Input, reified Output>
HttpClient.callEndpoint(endPoint: EndPoint<Route, Input, Output>, route : Route, input : Input) =
request(route){
method = endPoint.httpMethod
contentType(ContentType.Application.Json)
setBody(input)
}
There is also the ServiceResult classes and helper methods. You can check them in the full project.
Hope you like it and maybe something similar is added to Ktor officially.
You can see the full example here: https://github.com/mateuy-dev/ktor-endpoints-sample