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.
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.
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 a class 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, the Task class is annotated with the Serializable type from the kotlinx.serialization library.
Open the Routing.kt file and replace the existing code with the implementation below:
package com.example.plugins
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.
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/plugins, which includes the following:
install(ContentNegotiation) {
json()
}
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:
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 the Accept header set to application/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.
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/plugins.
Update the code for the /tasks route inside the Application.configureRouting() function with the following implementation:
package example.com.plugins
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 specified taskName.
/tasks/byPriority/{priority} returns tasks filtered by the specified priority.
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 Headers pane, set the value of the Accept header to application/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/json
To 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/plugins.
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.NoContent)
} catch (ex: IllegalStateException) {
call.respond(HttpStatusCode.BadRequest)
} catch (ex: JsonConvertException) {
call.respond(HttpStatusCode.BadRequest)
}
}
}
}
}
When a POST request is sent to /tasks the kotlinx.serialization framework is used to convert the body of the request into a Task object. If this succeeds, the task will be added to the repository. If the deserialization process fails the server will need to handle a JsonConvertException, whereas if the task is a duplicate it will need to handle an IllegalStateException.
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 Body pane add the following JSON document to represent a new task:
{
"name": "cooking",
"description": "Cook the dinner",
"priority": "High"
}
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/gardening
To 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 {
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 {
val response = client.get("/tasks/byPriority/Invalid")
assertEquals(HttpStatusCode.BadRequest, response.status)
}
@Test
fun unusedPriorityProduces404() = testApplication {
val response = client.get("/tasks/byPriority/Vital")
assertEquals(HttpStatusCode.NotFound, response.status)
}
@Test
fun newTasksCanBeAdded() = testApplication {
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.NoContent, 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 and kotlinx.serialization plugins into the Ktor client, in the same way as you did on the server.
Add the following dependencies into your build.gradle.kts file:
val ktor_version: String by project
//...
testImplementation("io.ktor:ktor-server-test-host:$ktor_version")
testImplementation("io.ktor:ktor-client-content-negotiation:$ktor_version")
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 an object is used.
Properties are being stored as numbers, when they are in fact strings.
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:
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 {
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 {
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.