How to create RESTful APIs in Kotlin with Ktor
In this tutorial, we’ll explain how to build a backend service using Kotlin and Ktor, featuring an example of a RESTful API that generates JSON files.
In the previous tutorial, we introduced you to the fundamentals of validation, error handling, and unit testing. This tutorial will expand on these topics by creating a RESTful service for managing tasks.
You will learn how to do the following:
Create RESTful services that use JSON serialization.
Understand the process of Content Negotiation.
Define the routes for a REST API within Ktor.
Prerequisites
You can do this tutorial independently, however, we strongly recommend that you complete the preceding tutorial to learn how to handle requests and generate responses.
We recommend that you install IntelliJ IDEA, but you could use another IDE of your choice.
Hello RESTful Task Manager
In this tutorial, you will be rewriting your existing Task Manager as a RESTful service. To do this you will use several Ktor plugins.
While you could manually add it to your existing project, it’s simpler to generate a new project and then incrementally add the code from the previous tutorial. You will reiterate all the code as you go, so you don’t need to have the previous project to hand.
Create a new project with plugins
Navigate to the Ktor Project Generator.
In the Project artifact field, enter com.example.ktor-rest-task-app as the name of your project artifact.
In the plugins section search for and add the following plugins by clicking on the Add button:
Routing
Content Negotiation
Kotlinx.serialization
Static Content
Once you have added the plugins, you will see all four plugins listed below the project settings.
Click the Download button to generate and download your Ktor project.
Add starter code
Open your project in IntelliJ IDEA, as previously described in the Create, open and run a Ktor project tutorial.
Navigate to src/main/kotlin/com/example and create a subpackage called model.
Inside the model package, create a new Task.kt file.
Open the Task.kt file and add an
enum
to represent priorities and aclass
to represent tasks:package com.example.model import kotlinx.serialization.Serializable enum class Priority { Low, Medium, High, Vital } @Serializable data class Task( val name: String, val description: String, val priority: Priority )In the previous tutorial you used extension functions to convert a
Task
into HTML. In this case, theTask
class is annotated with theSerializable
type from thekotlinx.serialization
library.Open the Routing.kt file and replace the existing code with the implementation below:
package com.example import com.example.model.* import io.ktor.server.application.* import io.ktor.server.http.content.* import io.ktor.server.response.* import io.ktor.server.routing.* fun Application.configureRouting() { routing { staticResources("static", "static") get("/tasks") { call.respond( listOf( Task("cleaning", "Clean the house", Priority.Low), Task("gardening", "Mow the lawn", Priority.Medium), Task("shopping", "Buy the groceries", Priority.High), Task("painting", "Paint the fence", Priority.Medium) ) ) } } }Similar to the previous tutorial, you’ve created a route for GET requests to the URL
/tasks
. This time, instead of manually converting the list of tasks, you are simply returning the list.In IntelliJ IDEA, click on the run button () to start the application.
Navigate to http://0.0.0.0:8080/tasks in your browser. You should see a JSON version of the list of tasks, as shown below:
Clearly a lot of work is being performed on our behalf. What exactly is going on?
Understand Content Negotiation
Content Negotiation via the browser
When you created the project you included the Content Negotiation plugin. This plugin looks at the types of content that the client can render and matches these against the content types that the current service can provide. Hence, the term Content Negotiation.
In HTTP the client signals which content types it can render through the Accept
header. The value of this header is one or more content types. In the case above you can examine the value of this header by using the development tools built into your browser.
Consider the following example:
Note the inclusion of */*
.This header signals that it accepts HTML, XML or Images - but it would also accept any other content type.
The Content Negotiation plugin needs to find a format to send data back to the browser. If you look inside the generated code in the project you will find a file called Serialization.kt inside src/kotlin/main/com/example, which includes the following:
This code installs the ContentNegotiation
plugin, and also configures the kotlinx.serialization
plugin. With this, when clients send requests the server can send back objects serialized as JSON.
In the case of the request from the browser the ContentNegotiation
plugin knows it can only return JSON, and the browser will try to display anything it is sent. So the request succeeds.
Content Negotiation via JavaScript
In a production environment, you wouldn’t want to display JSON directly in the browser. Instead, there would be JavaScript code running within the browser, which would make the request and then display the data returned as part of a Single Page Application (SPA). Typically, this kind of application would be written using a framework like React, Angular, or Vue.js.
To simulate this, open the index.html page inside src/main/resources/static and replace the default content with the following:
<html> <head> <title>A Simple SPA For Tasks</title> <script type="application/javascript"> function fetchAndDisplayTasks() { fetchTasks() .then(tasks => displayTasks(tasks)) } function fetchTasks() { return fetch( "/tasks", { headers: { 'Accept': 'application/json' } } ).then(resp => resp.json()); } function displayTasks(tasks) { const tasksTableBody = document.getElementById("tasksTableBody") tasks.forEach(task => { const newRow = taskRow(task); tasksTableBody.appendChild(newRow); }); } function taskRow(task) { return tr([ td(task.name), td(task.description), td(task.priority) ]); } function tr(children) { const node = document.createElement("tr"); children.forEach(child => node.appendChild(child)); return node; } function td(text) { const node = document.createElement("td"); node.appendChild(document.createTextNode(text)); return node; } </script> </head> <body> <h1>Viewing Tasks Via JS</h1> <form action="javascript:fetchAndDisplayTasks()"> <input type="submit" value="View The Tasks"> </form> <table> <thead> <tr><th>Name</th><th>Description</th><th>Priority</th></tr> </thead> <tbody id="tasksTableBody"> </tbody> </table> </body> </html>This page contains an HTML form and an empty table. Upon submitting the form a JavaScript event handler sends a request to the
/tasks
endpoint, with theAccept
header set toapplication/json
. The data returned is then de-serialized and added to an HTML table.In IntelliJ IDEA, click the rerun button () to restart the application.
Navigate to the URL http://0.0.0.0:8080/static/index.html. You should be able to fetch the data by clicking on the View The Tasks button:
Add the GET routes
Now that you are familiar with the process of content negotiation, continue with transferring the functionality from the previous tutorial into this one.
Reuse the Task Repository
You can reuse the repository for Tasks without any modification, so let’s do that first.
Inside the model package create a new TaskRepository.kt file.
Open TaskRepository.kt and add the code below:
package com.example.model object TaskRepository { private val tasks = mutableListOf( Task("cleaning", "Clean the house", Priority.Low), Task("gardening", "Mow the lawn", Priority.Medium), Task("shopping", "Buy the groceries", Priority.High), Task("painting", "Paint the fence", Priority.Medium) ) fun allTasks(): List<Task> = tasks fun tasksByPriority(priority: Priority) = tasks.filter { it.priority == priority } fun taskByName(name: String) = tasks.find { it.name.equals(name, ignoreCase = true) } fun addTask(task: Task) { if (taskByName(task.name) != null) { throw IllegalStateException("Cannot duplicate task names!") } tasks.add(task) } }
Reuse the routes for GET requests
Now that you’ve created the repository, you can implement the routes for GET requests. The previous code can be simplified because you no longer need to worry about converting tasks to HTML:
Navigate to the Routing.kt file in src/main/kotlin/com/example.
Update the code for the
/tasks
route inside theApplication.configureRouting()
function with the following implementation:package com.example import com.example.model.Priority import com.example.model.TaskRepository import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.http.content.* import io.ktor.server.response.* import io.ktor.server.routing.* fun Application.configureRouting() { routing { staticResources("static", "static") //updated implementation route("/tasks") { get { val tasks = TaskRepository.allTasks() call.respond(tasks) } get("/byName/{taskName}") { val name = call.parameters["taskName"] if (name == null) { call.respond(HttpStatusCode.BadRequest) return@get } val task = TaskRepository.taskByName(name) if (task == null) { call.respond(HttpStatusCode.NotFound) return@get } call.respond(task) } get("/byPriority/{priority}") { val priorityAsText = call.parameters["priority"] if (priorityAsText == null) { call.respond(HttpStatusCode.BadRequest) return@get } try { val priority = Priority.valueOf(priorityAsText) val tasks = TaskRepository.tasksByPriority(priority) if (tasks.isEmpty()) { call.respond(HttpStatusCode.NotFound) return@get } call.respond(tasks) } catch (ex: IllegalArgumentException) { call.respond(HttpStatusCode.BadRequest) } } } } }With this, your server can respond to the following GET requests:
/tasks
returns all tasks in the repository./tasks/byName/{taskName}
returns tasks filtered by the specifiedtaskName
./tasks/byPriority/{priority}
returns tasks filtered by the specifiedpriority
.
In IntelliJ IDEA, click the rerun button () to restart the application.
Test the functionality
Use the browser
You could test these routes in the browser. For example, navigate to http://0.0.0.0:8080/tasks/byPriority/Medium to see all the tasks with a Medium
priority displayed in JSON format:
Given that these kinds of requests will typically be coming from JavaScript, more fine-grained testing is preferable. For this, you can use a specialist tool such as Postman.
Use Postman
In Postman, create a new GET request with the URL
http://0.0.0.0:8080/tasks/byPriority/Medium
.In the
pane, set the value of the header toapplication/json
.Click Send to send the request and see the response in the response viewer.
Use an HTTP Request File
Within IntelliJ IDEA Ultimate you could perform the same steps in an HTTP Request file.
In the project root directory, create a new REST Task Manager.http file.
Open the REST Task Manager.http file and add the following GET request:
GET http://0.0.0.0:8080/tasks/byPriority/Medium Accept: application/jsonTo send the request within IntelliJ IDE, click on the gutter icon () next to it.
This will open and run in the Services tool window:
Add a route for POST requests
In the previous tutorial, tasks were created through an HTML form. However, as you are now building a RESTful service, you no longer need to do that. Instead, you will make use of the kotlinx.serialization
framework which will do the majority of the heavy lifting.
Open the Routing.kt file inside src/main/kotlin/com/example.
Add a new POST route to the
Application.configureRouting()
function as follows://... fun Application.configureRouting() { routing { //... route("/tasks") { //... //add the following new route post { try { val task = call.receive<Task>() TaskRepository.addTask(task) call.respond(HttpStatusCode.Created) } catch (ex: IllegalStateException) { call.respond(HttpStatusCode.BadRequest) } catch (ex: JsonConvertException) { call.respond(HttpStatusCode.BadRequest) } } } } }Add the following new imports:
//... import com.example.model.Task import io.ktor.serialization.* import io.ktor.server.request.*When a POST request is sent to
/tasks
thekotlinx.serialization
framework is used to convert the body of the request into aTask
object. If this succeeds, the task will be added to the repository. If the deserialization process fails the server will need to handle aJsonConvertException
, whereas if the task is a duplicate it will need to handle anIllegalStateException
.Restart the application.
To test this functionality in Postman, create a new POST request to the URL
http://0.0.0.0:8080/tasks
.In the
pane add the following JSON document to represent a new task:{ "name": "cooking", "description": "Cook the dinner", "priority": "High" }Click Send to send the request.
You can verify the task has been added by sending a GET request to http://0.0.0.0:8080/tasks.
Within IntelliJ IDEA Ultimate you could perform the same steps by adding the following to your HTTP Request file:
### POST http://0.0.0.0:8080/tasks Content-Type: application/json { "name": "cooking", "description": "Cook the dinner", "priority": "High" }
Add support for removals
You have almost finished adding the basic operations to your service. These are often summarized as the CRUD operations - short for Create, Read, Update, and Delete. The only operation you are missing is the Delete.
In the TaskRepository.kt file add the following method within the
TaskRepository
object to remove tasks based on their name:fun removeTask(name: String): Boolean { return tasks.removeIf { it.name == name } }Open the Routing.kt file and add an endpoint into the
routing()
function to handle DELETE requests:fun Application.configureRouting() { //... routing { route("/tasks") { //... //add the following function delete("/{taskName}") { val name = call.parameters["taskName"] if (name == null) { call.respond(HttpStatusCode.BadRequest) return@delete } if (TaskRepository.removeTask(name)) { call.respond(HttpStatusCode.NoContent) } else { call.respond(HttpStatusCode.NotFound) } } } } }Restart the application.
Add the following DELETE request to your HTTP Request File:
### DELETE http://0.0.0.0:8080/tasks/gardeningTo send the DELETE request within IntelliJ IDE, click on the gutter icon () next to it.
You will see the response in the Services tool window:
Create unit tests with Ktor Client
So far you have tested your application manually, but, as you’ve already noticed, this approach is time-consuming and will not scale. Instead, you can implement JUnit tests, using the built-in client
object to fetch and deserialize JSON.
Open the ApplicationTest.kt file within src/test/kotlin/com/example.
Replace the contents of the ApplicationTest.kt file with the following:
package com.example import com.example.model.Priority import com.example.model.Task import io.ktor.client.call.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.testing.* import kotlin.test.* class ApplicationTest { @Test fun tasksCanBeFoundByPriority() = testApplication { application { module() } val client = createClient { install(ContentNegotiation) { json() } } val response = client.get("/tasks/byPriority/Medium") val results = response.body<List<Task>>() assertEquals(HttpStatusCode.OK, response.status) val expectedTaskNames = listOf("gardening", "painting") val actualTaskNames = results.map(Task::name) assertContentEquals(expectedTaskNames, actualTaskNames) } @Test fun invalidPriorityProduces400() = testApplication { application { module() } val response = client.get("/tasks/byPriority/Invalid") assertEquals(HttpStatusCode.BadRequest, response.status) } @Test fun unusedPriorityProduces404() = testApplication { application { module() } val response = client.get("/tasks/byPriority/Vital") assertEquals(HttpStatusCode.NotFound, response.status) } @Test fun newTasksCanBeAdded() = testApplication { application { module() } val client = createClient { install(ContentNegotiation) { json() } } val task = Task("swimming", "Go to the beach", Priority.Low) val response1 = client.post("/tasks") { header( HttpHeaders.ContentType, ContentType.Application.Json ) setBody(task) } assertEquals(HttpStatusCode.Created, response1.status) val response2 = client.get("/tasks") assertEquals(HttpStatusCode.OK, response2.status) val taskNames = response2 .body<List<Task>>() .map { it.name } assertContains(taskNames, "swimming") } }Note that you need to install the
ContentNegotiation
andkotlinx.serialization
plugins into the Ktor client, in the same way as you did on the server.Add the following dependency into your version catalog located in gradle/libs.versions.toml:
[libraries] # ... ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor-version" }Add the new dependency into your build.gradle.kts file:
testImplementation(libs.ktor.client.content.negotiation)
Create unit tests with JsonPath
Testing your service with the Ktor client, or a similar library, is convenient, but it has a drawback from a Quality Assurance (QA) perspective. The server, not directly handling JSON, can't be certain about its assumptions regarding the JSON structure.
For example, assumptions like:
Values are being stored in an
array
when in reality anobject
is used.Properties are being stored as
numbers
, when they are in factstrings
.Members are being serialized in the order of declaration when they are not.
If your service is intended for use by multiple clients, it's crucial to have confidence in the JSON structure. To achieve this, use the Ktor Client to retrieve text from the server and then analyze this content using the JSONPath library.
In your build.gradle.kts file, add the JSONPath library to the
dependencies
block:testImplementation("com.jayway.jsonpath:json-path:2.9.0")Navigate to the src/test/kotlin/com/example folder and create a new ApplicationJsonPathTest.kt file.
Open the ApplicationJsonPathTest.kt file and add the following content to it:
package com.example import com.jayway.jsonpath.DocumentContext import com.jayway.jsonpath.JsonPath import io.ktor.client.* import com.example.model.Priority import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.server.testing.* import kotlin.test.* class ApplicationJsonPathTest { @Test fun tasksCanBeFound() = testApplication { application { module() } val jsonDoc = client.getAsJsonPath("/tasks") val result: List<String> = jsonDoc.read("$[*].name") assertEquals("cleaning", result[0]) assertEquals("gardening", result[1]) assertEquals("shopping", result[2]) } @Test fun tasksCanBeFoundByPriority() = testApplication { application { module() } val priority = Priority.Medium val jsonDoc = client.getAsJsonPath("/tasks/byPriority/$priority") val result: List<String> = jsonDoc.read("$[?(@.priority == '$priority')].name") assertEquals(2, result.size) assertEquals("gardening", result[0]) assertEquals("painting", result[1]) } suspend fun HttpClient.getAsJsonPath(url: String): DocumentContext { val response = this.get(url) { accept(ContentType.Application.Json) } return JsonPath.parse(response.bodyAsText()) } }The JsonPath queries work as follows:
$[*].name
means “treat the document as an array and return the value of the name property of each entry”.$[?(@.priority == '$priority')].name
means “return the value of the name property of every entry in the array with a priority equal to the supplied value”.
You can use queries like these to confirm your understanding of the returned JSON. When you do code refactoring and service redeployment, any modifications in serialization will be identified, even if they don't disrupt deserialization with the current framework. This allows you to republish publicly available APIs with confidence.
Next steps
Congratulations! You have now completed creating a RESTful API service for your Task Manager application and learned the nits and grits of unit testing with the Ktor Client and JsonPath.
Continue to the next tutorial to learn how to reuse your API service to build a web application.