A REST API with Ktor
This guide builds a small to-do list API with Ktor, a lightweight Kotlin web framework.
Create the project
Section titled “Create the project”kargo new todo-api -w ktor,serializationcd todo-api[frameworks]ktor = { server = "netty" }serialization = trueBecause serialization is enabled too, Kargo also adds Ktor’s JSON support and the content-negotiation plugin.
The project also gets resources/logback.xml, which logs at INFO. Without a Logback configuration the server would print every DEBUG line from Netty at startup. Raise or lower the level there, for example <root level="DEBUG"> while debugging.
Write the API
Section titled “Write the API”package com.example.todo_api
import io.ktor.http.HttpStatusCodeimport io.ktor.serialization.kotlinx.json.jsonimport io.ktor.server.application.Applicationimport io.ktor.server.application.installimport io.ktor.server.engine.embeddedServerimport io.ktor.server.netty.Nettyimport io.ktor.server.plugins.contentnegotiation.ContentNegotiationimport io.ktor.server.request.receiveimport io.ktor.server.response.respondimport io.ktor.server.routing.*import kotlinx.serialization.Serializableimport java.util.concurrent.ConcurrentHashMapimport java.util.concurrent.atomic.AtomicInteger
@Serializabledata class Todo(val id: Int = 0, val title: String, val done: Boolean = false)
class TodoStore { private val todos = ConcurrentHashMap<Int, Todo>() private val nextId = AtomicInteger(1)
fun all(): List<Todo> = todos.values.sortedBy { it.id } fun get(id: Int): Todo? = todos[id] fun add(todo: Todo): Todo = todo.copy(id = nextId.getAndIncrement()).also { todos[it.id] = it } fun delete(id: Int): Boolean = todos.remove(id) != null}
fun Application.module(store: TodoStore = TodoStore()) { install(ContentNegotiation) { json() } routing { route("/todos") { get { call.respond(store.all()) } post { val created = store.add(call.receive<Todo>()) call.respond(HttpStatusCode.Created, created) } get("/{id}") { val todo = call.parameters["id"]?.toIntOrNull()?.let(store::get) if (todo == null) call.respond(HttpStatusCode.NotFound) else call.respond(todo) } delete("/{id}") { val deleted = call.parameters["id"]?.toIntOrNull()?.let(store::delete) ?: false call.respond(if (deleted) HttpStatusCode.NoContent else HttpStatusCode.NotFound) } } }}
fun main() { embeddedServer(Netty, port = 8080) { module() }.start(wait = true)}Run it
Section titled “Run it”kargo runIn another terminal:
$ curl -X POST localhost:8080/todos -H 'Content-Type: application/json' -d '{"title":"Try Kargo"}'{"id":1,"title":"Try Kargo","done":false}$ curl localhost:8080/todos[{"id":1,"title":"Try Kargo","done":false}]Test it
Section titled “Test it”ktor-server-test-host is already on the test classpath. It runs your module in memory, without opening a port:
package com.example.todo_api
import io.ktor.client.request.*import io.ktor.client.statement.bodyAsTextimport io.ktor.http.ContentTypeimport io.ktor.http.HttpStatusCodeimport io.ktor.http.contentTypeimport io.ktor.server.testing.testApplicationimport kotlin.test.Testimport kotlin.test.assertEqualsimport kotlin.test.assertTrue
class TodoApiTest { @Test fun createsAndListsTodos() = testApplication { application { module() }
val created = client.post("/todos") { contentType(ContentType.Application.Json) setBody("""{"title":"Write tests"}""") } assertEquals(HttpStatusCode.Created, created.status)
val list = client.get("/todos").bodyAsText() assertTrue("Write tests" in list) }
@Test fun unknownTodoIs404() = testApplication { application { module() } assertEquals(HttpStatusCode.NotFound, client.get("/todos/42").status) }}kargo testShip it
Section titled “Ship it”kargo package --releasejava -jar target/release/jvm/todo-api-0.1.0-all.jarA minimal container image:
FROM eclipse-temurin:21-jreCOPY target/release/jvm/todo-api-0.1.0-all.jar /app.jarEXPOSE 8080ENTRYPOINT ["java", "-jar", "/app.jar"]More Ktor plugins
Section titled “More Ktor plugins”Add server plugins by name:
[frameworks]ktor = { server = "netty", server-plugins = ["cors", "call-logging", "status-pages", "auth"] }Each name x adds io.ktor:ktor-server-x at the same Ktor version. Clients work the same way with client and client-plugins.