Hero image

Building web APIs in Kotlin using the Ktor framework

Mar 26, 2024
Kotlin

In this article, we’ll create a simple web API using Ktor a framework for building web applications in Kotlin. We’ll demonstrate how to create endpoints that consume and produce JSON requests/responses.

Check out the example on GitHub: minibuildsio/ktor-example.

Getting started

The easiest way to create a Ktor application is to use the Ktor Project Generator https://start.ktor.io/.

Add the following plugins:

  • Routing: define endpoints to handle incoming requests.
  • Content Negotiation: converts request body based on the content type header.
  • kotlinx.serialization: serialize/deserialize JSON (and other formats if needed).

Add Features using Ktor Modules

Ktor uses modules to encapsulate specific features for example serialization and routing can be added to the application like so:

fun Application.module() {
    configureSerialization()
    configureRouting(PlaceService())
}

Content Negotiation and Serialization

The function below will install the ContentNegotiation plugin and register the JSON serializer. Custom serialization modules can

fun Application.configureSerialization() {
    install(ContentNegotiation) {
        json()
    }
}

To make a class serializable it needs to be annotated with @Serializable e.g.

@Serializable
data class Place(
    val id: Int,
    val name: String,
    val location: Location,
    val type: PlaceType
)

Routing

The function below will install the Routing plugin using the routing function, it’s equivalent to install(Routing) { ... }. Inside the routing call, you can call get("/endpoint"), post("/endpoint"), etc to set up handlers for HTTP requests for particular endpoints. Inside the handler, the call context provides access to the request query params, request body, and headers as well as the ability to set the response.

The get("/places") handler gets the query parameter called name and response with a list of places that match that name retrieved from the placesService.

fun Application.configureRouting(placeService: PlaceService) {
    routing {
        get("/places") {
            val name = call.request.queryParameters["name"]
            call.respond(placeService.getPlaces(name))
        }
    }

    // other routes...
}

Getting the request body

The request body can be accessed using the contexts receive function e.g. call.receive<...>(), with the type of the request body being provided in the generic parameter.

post("/visits") {
    val visit = call.receive<VisitRequest>()
    call.respond(visitService.addVisit(visit.placeId, visit.visitDateTime))
}

Using path variables

Path variables are supported using curly braces e.g. “/places/{id}”. The value of the path variable can be accessed using call.parameters e.g. call.parameters["id"].

get("/places/{id}") {
    val id = call.parameters["id"]!!.toInt()
    val place = placeService.getPlace(id)
    if (place == null) {
        call.respond(HttpStatusCode.NotFound)
    } else {
        call.respond(place)
    }
}

Testing a Ktor Application

Ktor provides the testApplication function to spin up an instance of the application. testApplication creates a client to interact with the running application, this allows you to make requests to the application and make assertions on the response.

@Test
fun `get places returns places from the places service`() = testApplication {
    client.get("/places").apply {
        assertEquals(HttpStatusCode.OK, status)
        assertEquals(DEFAULT_PLACES, Json.decodeFromString(bodyAsText()))
    }
}

Occasionally the default client won’t be configured to handle the response in that case the createClient function can be used to build a customised client. Below we create a client that adds a serializer module to parse LocalDateTime.

@Test
fun `post request to visits creates a visit`() = testApplication {
    val client = createClient {
        install(ContentNegotiation) {
            json(Json {
                serializersModule = SerializersModule {
                    contextual(LocalDateTimeSerializer)
                }
            })
        }
    }

    client.post("/visits") {
        contentType(ContentType.Application.Json)
        setBody(VisitRequest(10, DATETIME))
    }

    client.get("/visits").apply {
        val places: List<Visit> = body()
        assertEquals(1, places.size)
        assertEquals(10, places[0].placeId)
        assertEquals(DATETIME, places[0].visitDateTime)
    }
}