Getting started with Ktor | Kotlin.

In this article, we are going to learn how to build a server with the Ktor framework. This article will cover how to create a Ktor project using IntelliJ Ultimate and without it, build a basic server that returns a "Hello World" as a response, how to serve static files, and finally, an HTTP API that performs CRUD operations.

Ktor

Ktor is an asynchronous framework for creating microservices, web applications and more. Written in Kotlin from the ground up. More about it here.

Requirements

  • Java SDK installed

  • Gradle installed (Download it from here, install it and add it to the PATH environment)

How to create a Ktor project with IntelliJ Ultimate

We open IntelliJ Ultimate, and select "New Project".

Then, we select "Ktor" in the left panel, write a name for the project, choose a "Location" and "Build system" if we want, or we can leave the default values, and click on "Next".

We select the plugins we are going to use, for start this example, we will use "Routing" and "Call Logging". After we select the plugins, we click on "Create".

IntelliJ creates the project and generates the plugins files we are going to use, and the Application.kt file.

We open the Application.kt file, and click on the "Play" button. There are two buttons, anyone works.

The building process will start.

Once is completed, we will have the app running.

If we navigate to localhost:8080, we should see the following message.

How to create a Ktor with another code editor.

To create a new Ktor project, we use the Ktor project generator on this web page.

Then, we click on "Add plugins", and select "Routing" and "Call Logging".

We click on "Generate project" and a .zip folder will be downloaded to our machine.

We unzip it and open the folder with a code editor. For this example, I will use VS Code.

We execute the gradle run command in the root folder.

After the building is completed, we will see this output in the command line:

If we navigate to localhost:8080, we should see the following message:

Ktor server

Src/main/kotlin Folder

In the src/main/kotlin folder we have the Application.kt file and the plugins folder which contains the Routing.kt and Monitoring.kt files.

Application.kt

package com.example

import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import com.example.plugins.*

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
            .start(wait = true)
}

fun Application.module() {
    configureMonitoring()
    configureRouting()
}

The Application.kt file is the main entry point for a Ktor application.

The main() function in the Application.kt file is responsible for starting the Ktor server. The module() function is responsible for configuring the application and loading the plugins. In the code snippet above, configureMonitoring() install the plugin responsible for collecting the metrics of the app. Also, this file is where we define the port and host from where our application should start listening.

Routing.kt

package com.example.plugins

import io.ktor.server.routing.*
import io.ktor.server.response.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.http.*
import io.ktor.server.application.*

fun Application.configureRouting() {
    install(StatusPages) {
        exception<Throwable> { call, cause ->
            call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
        }
    }
    routing {
        get("/") {
            call.respondText("Hello World!")
        }
    }
}

The routing() function in the Routing.kt file is responsible for defining routes. The routing() function takes a block of code as its argument. The code in the block of code defines the routes for the application.

The routes are defined using the get(), post(), put(), delete(), and head() functions. These functions take a path as their argument and a handler as their return value.

The handler is the function that will be called when a request is made to the specified path. Inside the handler, we can get access to the ApplicationCall, which handles client requests and send the defined response.

In the code snippet above the configureRouting() function installs the StatusPages plugin and configures it to respond to all Throwable exceptions with a 500 Internal Server Error response.

The routing() function defines a route that responds to GET requests to the / path. Then, the handler for this route simply responds with the text "Hello World!".

The StatusPages plugin allows you to customize the way that Ktor responds to errors. The exception handler allows you to handle calls that result in a Throwable exception. In the code above it will throw the 500 HTTP status code for any exception.

Installing the StatusPages plugin in the configureRouting() function allows this plugin to be applied to all routes in the application.

For more information about the Routing and StatusPages plugins, you can consult the documentation here.

Serving Static Files

We are going to serve an HTML file. First, we create a folder in the root directory, called "static" with an HTML file in there.

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Index File</title>
</head>
<body>
    <h1>Hello, this is a HTML file</h1>

</body>
</html>

Then, we go to Routing.kt and add the following code in the Application.configureRouting() function.

routing {
        staticFiles("/static", File("static"), index="index.html")
...
}

This maps /static to the folder "static" and serve the index.html file as default.

package com.example.plugins

import io.ktor.server.routing.*
import io.ktor.server.response.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.http.content.*
import io.ktor.server.routing.get
import java.io.File

fun Application.configureRouting() {
    install(StatusPages) {
        exception<Throwable> { call, cause ->
            call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
        }
    }
    routing {
        staticFiles("/static", File("static"), index="index.html")

        get("/") {
            call.respondText("Hello World!")
        }
    }
}

In Routing.kt, we import io.ktor.server.http.content.* , io.ktor.server.routing.get , java.io.File and use the staticFiles function to define the route that serves the file, the folder and the file that is served as default.

We run the server and navigate to localhost:8080/static. We should receive the following response:

Also, if we don't want to define a default file, we just keep the staticFile function like this:

routing {
        staticFiles("/static", File("static"))

As the documentation says, Ktor recursively serves up any file from static as long as the URL path and the filename match.

For example, if we have more than one file in the folder "static", and the filename of one of them is hello.html, to serve it, the URL path would be /static/hello.html .

HTTP API

Adding serialization plugin

For the HTTP API, we need to install the serialization plugin.

We open build.gradle.kts file and add the following dependency:

implementation("io.ktor:ktor-server-content-negotiation:$ktor_version")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")

Also, we have to declare the plugin in the same file.

plugins {
    id("org.jetbrains.kotlin.plugin.serialization") version "1.8.0"
}

Complete build.gradle.kts.

val ktor_version: String by project
val kotlin_version: String by project
val logback_version: String by project

plugins {
    kotlin("jvm") version "1.9.0"
    id("io.ktor.plugin") version "2.3.2"
}

group = "com.example"
version = "0.0.1"
application {
    mainClass.set("com.example.ApplicationKt")

    val isDevelopment: Boolean = project.ext.has("development")
    applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("io.ktor:ktor-server-core-jvm:$ktor_version")
    implementation("io.ktor:ktor-server-host-common-jvm:$ktor_version")
    implementation("io.ktor:ktor-server-status-pages-jvm:$ktor_version")
    implementation("io.ktor:ktor-server-call-logging-jvm:$ktor_version")
    implementation("io.ktor:ktor-server-netty-jvm:$ktor_version")
    implementation("ch.qos.logback:logback-classic:$logback_version")
    implementation("io.ktor:ktor-server-content-negotiation:$ktor_version")
    implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
    testImplementation("io.ktor:ktor-server-tests-jvm:$ktor_version")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
}

Application.kt

We have to declare a function inside the Application.module() function that loads the serialization plugin.

package com.example

import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import com.example.plugins.*

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
            .start(wait = true)
}

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

In the code above, we add configureSerialization(), which is responsible for loading the serialization plugin. But, we have not define that function yet.

Now, we create the Serialization.kt file inside the plugins folder.

Serialization.kt

package com.example.plugins


import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*

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

In this file, we define the configureSerialization() function, responsible for installing the ContentNegotiation plugin.

Users.kt

We create the models package inside the src/main/kotlin/com/example folder.

package com.example.models

import kotlinx.serialization.Serializable

@Serializable
data class User(val id: String, val firstName: String, val lastName: String, val email: String)

In the code above, we import the kotlinx.serialization.Serializable library and, we define a data class called User. The User class has four properties: id , firstName , lastName , email.

The @Serializable annotation tells the Kotlin compiler to generate code that can be used to serialize and deserialize User objects.

For this example app, we are not going to use a database.

In the same file, we declare a mutable list of User objects called userStorage. The mutableListOf function creates a new list that can be modified.

val userStorage = mutableListOf<User>()

The User objects in the list can be added, removed, or changed at any time. This way we will be able to have CRUD functionality.

We create a package named routes, inside the src/main/kotlin/com/example folder.

Then, inside the routes package, we create the UserRouter.kt file.

UserRouter.kt

package com.example.routes

import com.example.models.*
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Route.userRouting() {
    route("/user") {
        get {

        }
        get("{id}") {

        }
        post {

        }
        put("{id}") {

        }
        delete("{id}") {

        }
    }
}

In this file, we declare the userRouting function, in which we group all the routes for the /user endpoint.

GET route

package com.example.routes

import com.example.models.*
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Route.userRouting() {
    route("/user") {
        get {
            if (userStorage.isNotEmpty()) {
                call.respond(userStorage)
            } else {
                call.respondText ("No users found", status = HttpStatusCode.OK)
            }

        }
        ...
    }
}

Now, we added a handler for the get route to see if there are any users in the userStorage list. If there are no users in the list, the route will respond with a 200 OK status code and the text "No users found".

Before we start the server to try this functionality, we have to go Routing.kt file and declare the userRouting() function inside the configureRouting() function.

Routing.kt


...
import com.example.routes.*


fun Application.configureRouting() {
    install(StatusPages) {
        exception<Throwable> { call, cause ->
            call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
        }
    }
    routing {
        staticFiles("/static", File("static"))

        get("/") {
            call.respondText("Hello World!")
        }
        userRouting()
    }
}

Now we can start the server and navigate to localhost:8080/user. We should receive the following response:

We received that message because so far there is no data in the userStorage.

Let's add a handler to create a user.

POST route

 post {
    val customer = call.receive<User>()
    customerStorage.add(user)
    call.respondText("User stored correctly", status = HttpStatusCode.Created)

 }

We run the server. And use an HTTP client to make a POST request.

If we navigate to localhost:8080/user or make a GET request with an HTTP client, we should see the following response.

GET ID route

get("{id}") {
  val id = call.parameters["id"] ?: return@get call.respondText(
       "Missing id",
       status = HttpStatusCode.BadRequest
       )
 val user =
     userStorage.find { it.id == id } ?: return@get call.respondText(
     "No user with id $id",
      status = HttpStatusCode.NotFound
      )
 call.respond(user)
}

This handler retrieves the user with the specified ID from the userStorage list and returns it to the client. If the user does not exist, the handler returns a 404 Not Found error.

The handler first uses the call.parameters function to get the value of the id parameter from the request. If the id parameter is not present, the handler returns a 400 Bad Request error.

Next, the handler uses the userStorage.find function to find the user with the specified ID in the userStorage list. If the user is not found, the handler returns a 404 Not Found error.

If the user is found, the handler returns the user to the client. The respond function sends a response to the client.

PUT route

put("{id}") {
 val id = call.parameters["id"] ?: return@put call.respondText(
     "No id provided",
     status = HttpStatusCode.BadRequest
     )
 val user =
     userStorage.find { it.id == id } ?: return@put call.respondText(
      "No user with this id: $id",
       status = HttpStatusCode.NotFound
      )
 val newData = call.receive<User>()
 val indexUser = userStorage.indexOf(user)
 userStorage[indexUser] = newData
 call.respondText("User updated", status = HttpStatusCode.OK)      
}

Now, we implement the option that allows the clients to updates an element in the userStorage. This handler updates the user with the specified ID with the data from the request body. If the user does not exist, the handler returns a 404 Not Found error.

The handler first uses the call.parameters function to get the value of the id parameter from the request. If the id parameter is not present, the handler returns a 400 Bad Request error.

Next, the handler uses the userStorage.find function to find the user with the specified ID in the userStorage list. If the user is not found, the handler returns a 404 Not Found error.

If the user is found, the handler uses the call.receive<User> function to read the data from the request body and create a new User object. The new User object is then used to update the existing user in the userStorage list. The index of the existing user in the list is used to ensure that the correct user is updated.

Finally, the handler returns a 200 OK status code and the text "User updated".

DELETE route

delete("{id}") {
  val id = call.parameters["id"] ?: return@delete call.respond(HttpStatusCode.BadRequest)
  if (userStorage.removeIf{ it.id == id}) {
      call.respondText("User removed correctly", status = HttpStatusCode.Accepted)

   } else {
       call.respondText("User Not Found", status = HttpStatusCode.NotFound)
   }
}

In the DELETE route, with a specified ID, this handler deletes a user from the userStorage list. If the user does not exist, the handler returns a 404 Not Found error.

Similar to the GET and PUT handlers, the delete handler first uses the call.parameters function to get the value of the id parameter from the request. If the id parameter is not present, the handler returns a 400 Bad Request error.

Next, the handler uses the userStorage.removeIf function to remove the user with the specified ID from the userStorage list. If the user is not found, the handler returns a 404 Not Found error.

Finally, the handler returns a 202 Accepted status code if the user was deleted successfully, or a 404 Not Found status code if the user was not found.

After we have created all the routes, we go to the file kotlin/com/example/plugins/Routing.kt and add the userRouting() function to the Application.configureRouting().

package com.example.plugins

import io.ktor.server.routing.*
import io.ktor.server.response.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.http.content.*
import io.ktor.server.routing.get
import java.io.File

import com.example.routes.*

fun Application.configureRouting() {
    install(StatusPages) {
        exception<Throwable> { call, cause ->
            call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
        }
    }
    routing {
        staticFiles("/static", File("static"))

        get("/") {
            call.respondText("Hello World!")
        }
        userRouting()
    }
}

Monitoring Plugin

When we create this project, we select the Call logging plugin. With this plugin, we can see in the console every request made to the server.

package com.example.plugins

import io.ktor.server.plugins.callloging.*
import org.slf4j.event.*
import io.ktor.server.request.*
import io.ktor.server.application.*

fun Application.configureMonitoring() {
    install(CallLogging) {
        level = Level.INFO
        filter { call -> call.request.path().startsWith("/") }
    }
}

The CallLogging plugin logs information about every incoming request, including the request method, path, headers, and body. The configureMonitoring function configures the CallLogging plugin with the following settings:

  • The log level is set to INFO. This means that only informational messages will be logged.

  • The filter function is used to only log requests that start with the "/" path.

Conclusion

In this article, we have learned how to build a server with Ktor that serves static files and performs CRUD operations through an HTTP API. We started by creating a new project and adding plugins. We also learned how to use Ktor's routing features to create an endpoint.

In my opinion, Ktor has amazing documentation. I don't have any experience using Kotlin, but it was easy to learn, and it allows me to learn quickly how to build a server.

Thank you for taking the time to read this article.

If you have any recommendations about other packages, architectures, how to improve my code, my English, or anything; please leave a comment or contact me through Twitter, or LinkedIn.

The source code is here.

Resources

Creating HTTP APIs

Creating a new Ktor project

Routing

Content negotiation and serialization

Call logging

Ktor README