Ktor 3.0.0-beta-1 Help

Use Ktor and Kotlin to handle HTTP requests and generate responses

In this tutorial, you’ll learn the basics of routing, handling requests, and parameters in Kotlin with Ktor by building a task manager application.

By the end of this tutorial you will know how to do the following:

  • Handle GET and POST requests.

  • Extract information from requests.

  • Handle errors when converting data.

  • Use unit tests to validate routing.

Prerequisites

This is the second tutorial of the Get started with Ktor Server guide. You can do this tutorial independently, however, we strongly recommend that you complete the preceding tutorial to learn how to Create, open, and run a new Ktor project.

It is also very useful to have a basic understanding of HTTP request types, headers and status codes.

We recommend that you install IntelliJ IDEA, but you could use another IDE of your choice.

Task Manager Application

In this tutorial you will incrementally build a Task Manager application with the following functionality:

  • View all the available tasks as an HTML table.

  • View tasks by priority and name, again as HTML.

  • Add additional tasks by submitting an HTML form.

You will do the minimum possible to get some basic functionality working, and then improve and extend this functionality over seven iterations. This minimum functionality will consist of a project containing some model types, a list of values and a single route.

Display static HTML content

In the first iteration you will add a new route to your application that will return static HTML content.

Using the Ktor Project Generator, create a new project called ktor-task-app. You can accept all the default options, but may wish to change the artifact name.

  1. Open the Routing.kt file within the src/main/kotlin/com/example/plugins folder.

  2. Replace the existing Application.configureRouting() function with the implementation below:

    fun Application.configureRouting() { routing { get("/tasks") { call.respondText( contentType = ContentType.parse("text/html"), text = """ <h3>TODO:</h3> <ol> <li>A table of all the tasks</li> <li>A form to submit new tasks</li> </ol> """.trimIndent() ) } } }

    With this you have created a new route for the URL /tasks and the GET request type. A GET request is the most basic request type in HTTP. It is triggered when the user types into the browser's address bar or clicks on a regular HTML link.

    For the moment you are just returning static content. To notify the client that you will be sending HTML, you set the HTTP Content Type header to "text/html".

  3. Add the following import to access the ContentType object:

    import io.ktor.http.ContentType
  4. In Intellij IDEA, click on the run gutter icon (intelliJ IDEA run application icon) next to the main() function in Application.kt to start the application.

  5. Navigate to http://0.0.0.0:8080/tasks in your browser. You should see the to-do list displayed:

    A browser window displaying a to-do list with two items

Implement a Task Model

Now that you have created the project and set up basic routing, you will extend your application by doing the following:

  1. Create model types to represent tasks.

  2. Declare a list of tasks containing sample values.

  3. Modify the route and request handler to return this list.

  4. Test that the new feature works using the browser.

Create model types

  1. Inside src/main/kotlin/com/example create a new subpackage called model.

  2. Within the model directory create a new Task.kt file.

  3. Open the Task.kt file add the following enum to represent priorities and a class to represent tasks:

    enum class Priority { Low, Medium, High, Vital } data class Task( val name: String, val description: String, val priority: Priority )
  4. You will be sending task information to the client inside HTML tables, so also add the following extension functions:

    fun Task.taskAsRow() = """ <tr> <td>$name</td><td>$description</td><td>$priority</td> </tr> """.trimIndent() fun List<Task>.tasksAsTable() = this.joinToString( prefix = "<table rules=\"all\">", postfix = "</table>", separator = "\n", transform = Task::taskAsRow )

    The function Task.taskAsRow() enables Task objects to be rendered as table rows, whilst List<Task>.tasksAsTable() allows a list of tasks to be rendered as a table.

Create sample values

  1. Inside your model directory create a new TaskRepository.kt file.

  2. Open TaskRepository.kt and add the following code to define a list of Tasks:

    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) )

Add a new route

  1. Open the Routing.kt file and replace the existing Application.configureRouting() function with the implementation below:

    fun Application.configureRouting() { routing { get("/tasks") { call.respondText( contentType = ContentType.parse("text/html"), text = tasks.tasksAsTable() ) } } }

    Instead of returning static content to the client, you are now providing a list of tasks. As a list cannot be sent over the network directly, it must be converted into a format the client will understand. In this case the tasks are converted into an HTML table.

  2. Add the required import:

    import model.*

Test the new feature

  1. In intelliJ IDEA, click on the rerun button (intelliJ IDEA rerun button icon) to restart the application.

  2. Navigate to http://0.0.0.0:8080/tasks in your browser. It should display an HTML table containing tasks:

    A browser window displaying a table with four rows

    If so, congratulations! The basic functionality of the application is working correctly.

Refactor the model

Before you continue with extending your app's functionality, you need to refactor the design by encapsulating the list of values within a repository. This will allow you to centralize your data management and thereby focus on the Ktor specific code.

  1. Return to the TaskRepository.kt file and replace the existing list of tasks with the code below:

    package 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) } }

    This implements a very simple data store for tasks based on a list. For the purposes of the example, the order in which the tasks are added will be preserved, but duplicates will be disallowed by throwing an exception.

    In later tutorials you will learn how to implement repositories which connect to relational databases through the Exposed library.

    For now, you will utilise the repository inside your route.

  2. Open the Routing.kt file and replace the existing Application.configureRouting() function with the implementation below:

    fun Application.configureRouting() { routing { get { val tasks = TaskRepository.allTasks() call.respondText( contentType = ContentType.parse("text/html"), text = tasks.tasksAsTable() ) } } }

    When a request arrives, the repository is used to fetch the current list of tasks. Then, an HTTP response is built containing these tasks.

Test the refactored code

  1. In intelliJ IDEA, click on the rerun button (intelliJ IDEA rerun button icon) to restart the application.

  2. Navigate to http://0.0.0.0:8080/tasks in your browser. The output should remain the same with the HTML table displayed:

    A browser window displaying a table with four rows

Work with parameters

In this iteration, you will allow the user to view tasks by priority. To do this, your application must allow GET requests to the following URLs:

The route you would add is /tasks/byPriority/{priority} where {priority} represents a query parameter that you will need to extract at runtime. The query parameter can have any name you like, but priority seems the obvious choice.

The process to handle the request can be summarized as follows:

  1. Extract a query parameter called priority from the request.

  2. If this parameter is absent, return a 400 status (Bad Request).

  3. Convert the text value of the parameter into a Priority enum value.

  4. If this fails, return a response with a 400 status code.

  5. Use the repository to find all tasks with the specified priority.

  6. If there are no matching tasks, return a 404 status (Not Found).

  7. Return the matching tasks, formatted as an HTML table.

You will first implement this functionality, and then find the best way to check it is working.

Add a new route

Open the Routing.kt file and add the following route into your code, as shown below:

routing { get("/tasks") { ... } //add the following route get("/tasks/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.respondText( contentType = ContentType.parse("text/html"), text = tasks.tasksAsTable() ) } catch(ex: IllegalArgumentException) { call.respond(HttpStatusCode.BadRequest) } } }

As summarized above, you have written a handler for the URL /tasks/byPriority/{priority}. The symbol priority represents the query parameter that the user has added. Unfortunately, on the server there's no way of guaranteeing that this is one of the four values in the corresponding Kotlin enumeration, so it must be checked manually.

If the query parameter is absent, the server returns a 400 status code to the client. Otherwise, it extracts the value of the parameter and tries to convert it to a member of the enumeration. Should this fail, an exception will be thrown, which the server catches and returns a 400 status code.

Assuming the conversion succeeds, the repository is used to find the matching Tasks. If there are no tasks of the specified priority the server returns a 404 status code, otherwise it sends the matches back in an HTML table.

    Test the new route

    You can test this functionality in the browser by requesting the different URLs.

    1. In intelliJ IDEA, click on the rerun button (intelliJ IDEA rerun button icon) to restart the application.

    2. To retrieve all the medium priority tasks navigate to http://0.0.0.0:8080/tasks/byPriority/Medium:

      A browser window displaying a table with Medium priority tasks
    3. Unfortunately, the testing you can do through the browser is limited in the case of errors. The browser will not show the details of an unsuccessful response, unless you use developer extensions. A simpler alternative would be to use a specialist tool, such as Postman.

    4. In Postman, send a GET request for the same URL http://0.0.0.0:8080/tasks/byPriority/Medium.

      A GET request in Postman showing the response details

      This shows the raw output from the server, plus all the details of the request and response.

    5. To check that a 404 status code is returned on request for vital tasks, send a new GET request to http://0.0.0.0:8080/tasks/byPriority/Vital. You will then see the status code displayed in the upper right corner of the Response pane.

      A GET request in Postman showing the status code
    6. To verify that a 400 is returned when an invalid priority is specified, create another GET request with an invalid property:

      A GET request in Postman with a Bad Request status code

    Add unit tests

    So far you have added two routes - one for retrieving all tasks and one for retrieving tasks by priority. Tools like Postman enable you to fully test these routes, but they require manual inspection and run externally to Ktor.

    This is acceptable when prototyping and in small applications. However, this approach does not scale to large applications where there may be thousands of tests that need to run frequently. A better solution is to fully automate your testing.

    Ktor provides its own test framework to support the automated validation of routes. Next, you will write some tests for your app's existing functionality.

    1. Create a new directory within src called test and a subdirectory called kotlin.

    2. Inside src/test/kotlin create a new ApplicationTest.kt file.

    3. Open the ApplicationTest.kt file and add the following code:

      package com.example import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.server.testing.* import org.junit.Test import kotlin.test.assertContains import kotlin.test.assertEquals class ApplicationTest { @Test fun tasksCanBeFoundByPriority() = testApplication { application { module() } val response = client.get("/tasks/byPriority/Medium") val body = response.bodyAsText() assertEquals(HttpStatusCode.OK, response.status) assertContains(body, "Mow the lawn") assertContains(body, "Paint the fence") } @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) } }

      In each of these tests a new instance of Ktor is created. This is running inside a test environment, instead of a web server like Netty. The module written for you by the Project Generator is loaded, which in turn invokes the routing function. You can then use the built-in client object to send requests to the application, and to validate the responses that are returned.

      The test can be run within the IDE or as part of your CI/CD pipeline.

    4. To run the tests within the IntelliJ IDE, click on the gutter icon (intelliJ IDEA gutter icon) next to each test function.

    Handle POST requests

    You can follow the process described above to create any number of additional routes for GET requests. These would allow the user to fetch tasks using whatever search criteria we like. But users will also want to be able to create new tasks.

    In that case the appropriate type of HTTP request is a POST. A POST request is typically triggered when a user completes and submits an HTML form.

    Unlike a GET request, a POST request has a body, which contains the names and values of all the inputs that are present on the form. This information is encoded to separate the data from different inputs and to escape illegal characters. You do not need to worry about the details of this process, as the browser and Ktor will manage it for us.

    Next, you'll extend your existing application to allow the creation of new tasks in the following steps:

    1. Create a static content folder, containing an HTML form.

    2. Make Ktor aware of this folder, so its contents can be served.

    3. Add a new request handler to process the form submission.

    4. Test the finished functionality.

    Create the static content

    1. Inside src/main/resources create a new directory called task-ui. This will be the folder for your static content.

    2. Within the task-ui folder, create a new task-form.html file.

    3. Open the newly created task-form.html file and add the following content to it:

      <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Adding a new task</title> </head> <body> <h1>Adding a new task</h1> <form method="post" action="/tasks"> <div> <label for="name">Name: </label> <input type="text" id="name" name="name" size="10"> </div> <div> <label for="description">Description: </label> <input type="text" id="description" name="description" size="20"> </div> <div> <label for="priority">Priority: </label> <select id="priority" name="priority"> <option name="Low">Low</option> <option name="Medium">Medium</option> <option name="High">High</option> <option name="Vital">Vital</option> </select> </div> <input type="submit"> </form> </body> </html>

    Register the folder with Ktor

    1. Navigate to the Routing.kt file within src/main/kotlin/com/example/plugins.

    2. Add the following call to staticResources() into the Application.configureRouting() function:

      fun Application.configureRouting() { routing { //add the following line staticResources("/task-ui", "task-ui") get("/tasks") { ... } get("/tasks/byPriority/{priority}") { … } } }

      This will require the following import:

      import io.ktor.server.http.content.staticResources
    3. Restart the application.

    4. Navigate to http://0.0.0.0:8080/task-ui/task-form.html in your browser. The HTML form should be displayed:

      A browser window displaying an HTML form

    Add a handler for the form

    In Routing.kt add the following additional route into the configureRouting() function:

    fun Application.configureRouting() { routing { //... //add the following route post("/tasks") { val formContent = call.receiveParameters() val params = Triple( formContent["name"] ?: "", formContent["description"] ?: "", formContent["priority"] ?: "" ) if (params.toList().any { it.isEmpty() }) { call.respond(HttpStatusCode.BadRequest) return@post } try { val priority = Priority.valueOf(params.third) TaskRepository.addTask( Task( params.first, params.second, priority ) ) call.respond(HttpStatusCode.NoContent) } catch (ex: IllegalArgumentException) { call.respond(HttpStatusCode.BadRequest) } catch (ex: IllegalStateException) { call.respond(HttpStatusCode.BadRequest) } } } }

    As you can see the new route is mapped to POST requests rather than GET requests. Ktor processes the body of the request via the call to receiveParameters(). This returns a collection of the parameters that were present in the body of the request.

    There are three parameters, so you can store the associated values in a Triple. If a parameter is not present then an empty string is stored instead.

    If any of the values are empty, the server will return a response with a status code of 400. Then, it will attempt to convert the third parameter to a Priority and, if successful, add the information to the repository in a new Task. Both of these actions may result in an exception, in which case once again return a status code 400.

    Otherwise, if everything is successful, the server will return a 204 status code ( No Content) to the client. This signifies that their request has succeeded, but there's no fresh information to send them as a result.

      Test the finished functionality

      1. Restart the application.

      2. Navigate to http://0.0.0.0:8080/task-ui/task-form.html in the browser.

      3. Fill in the form with sample data and click Submit.

        A browser window displaying an HTML form with sample data

        When you submit the form you should not be directed to a new page.

      4. Navigate to the URL http://0.0.0.0:8080/tasks. You should see that the new task has been added.

        A browser window displaying an HTML table with tasks
      5. To validate the functionality, add the following test to ApplicationTest.kt:

        @Test fun newTasksCanBeAdded() = testApplication { application { module() } val response1 = client.post("/tasks") { header( HttpHeaders.ContentType, ContentType.Application.FormUrlEncoded.toString() ) setBody( listOf( "name" to "swimming", "description" to "Go to the beach", "priority" to "Low" ).formUrlEncode() ) } assertEquals(HttpStatusCode.NoContent, response1.status) val response2 = client.get("/tasks") assertEquals(HttpStatusCode.OK, response2.status) val body = response2.bodyAsText() assertContains(body, "swimming") assertContains(body, "Go to the beach") }

        In this test two requests are sent to the server, a POST request to create a new task and a GET request to confirm the new task has been added. When making the first request, the setBody() method is used to insert content into the body of the request. The test framework provides a formUrlEncode() extension method on collections, which abstracts the process of formatting the data as the browser would.

      Refactor the routing

      If you examine your routing thus far you will see that all the routes begin with /tasks. You can remove this duplication by placing them into their own sub-route:

      fun Application.configureRouting() { routing { staticResources("/task-ui", "task-ui") route("/tasks") { get { //Code remains the same } get("/byPriority/{priority}") { //Code remains the same } post { //Code remains the same } } }

      If your application reached the stage where you had multiple sub-routes, then it would be appropriate to put each into its own helper function. However, this is not required at present.

      The better organized your routes are the easier it is to extend them. For example, you could add a route for finding tasks by name:

      fun Application.configureRouting() { routing { staticResources("/task-ui", "task-ui") route("/tasks") { get { //Code remains the same } 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.respondText( contentType = ContentType.parse("text/html"), text = listOf(task).tasksAsTable() ) } get("/byPriority/{priority}") { //Code remains the same } post { //Code remains the same } } } }

      Next steps

      You have now implemented basic routing and request handling functionality. In addition, you were introduced to validation, error handling, and unit testing. All these topics will be expanded in subsequent tutorials.

      Continue to the next tutorial to learn how to create a RESTful API for your task manager that generates JSON files.

      Last modified: 31 May 2024