17 minutes read

We are already familiar with running the simplest Ktor application. Now it's time to figure out how to handle requests for different approaches, how in Ktor this mechanics is arranged with the magic of Kotlin!

A little bit about the insides

As we have already pointed out, Kotlin puts a lot of emphasis on functional programming, and Ktor is no exception. Let's look at a sample code for a project:

fun main() {
    embeddedServer(Netty) {
        configureRouting()
    }.start()
}
fun Application.configureRouting() {
    routing {
        get("/") {
            call.respondText("Hello World!")
        }
    }
    routing {
    }
}

Here, we call embeddedServer function, which we already know. Let's see what happens next. In addition to various parameters (for example, engine = Netty), it takes the function configureRouting() which is an extension function above the Application class. In this function we will bring out the logic of defining paths for HTTP requests.

Since it is an extension function, we may call it inside embeddedServer, thereby calling it and other configure functions inside these curly braces. Here, embeddedServer is not just a higher-order function (a function that has an argument of another function), but also a lambda with a receiver. It takes an extension function, so anything used in the configureRouting function has access to the fields and methods of the Application class. Below, there is a part of the embeddedServer method signature, showing us that the trailing lambda parameter is a lambda with a receiver. So we write the module configuration functions in curly brackets:

fun embeddedServer(
    // ...
    module: Application.() -> Unit
) { // ...
}

Let's talk about routing:

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

routing — also accepts an extension function. It is an extension of the Application class, it accepts the Routing class extension functions:

fun Application.routing(configuration: Routing.() -> Unit)

Routing is inherited from the Route class, which already has functions such as get, post, and so on. We use them freely to set up paths inside the routing block.

What is the call object and where does it come from?

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

call is an extension field of the class PipelineContext, which is passed to the last argument of the function get. Again, it turns out that we are dealing with a lambda with a receiver. We have a call object inside the get, post, and other methods — it's responsible for handling the incoming request and the return of the response. With the help of the call object we can get different information about the received requests — parameters, headers, etc. We'll tell you about this in further topics!

You should note that here we thanks to the Kotlin features (trailing lambda, lambda with receiver, extension functions), we achieve a nice concise DSL to describe our logic!

Now let's move on to practice!

Determining routes

Before we return something for specific routes, let's see how to return something with a call object.

The easiest option is to use the respondText function. In addition to the text that will be returned in the body of the response, the contentType and status parameters can also be specified — which are, in fact, directly related to the HTTP protocol. We will discuss these parameters in more detail in further topics!

respondText is just an add-on to the respond generic function, which accepts only some messages (with no other options like status and so on). It can be text, among other things:

call.respondText("Hello World!")

In the browser we will see:

localhost page with "Hello World!"

To see the response's information in Google Chrome, right-click on the page and choose View Page Source. After clicking on the Network tab, refresh the page. Done! Your browser intercept your network activity and now you see the info about the HTTP request.

Network tab in browser's developer tools

Also, we can test the requests in Intellij IDEA Ultimate Edition. Let's try to create a special file for writing requests:

HTTP request file template

Let's write the following query and click on the green button:

test.http request file

In the console we will see a response with additional HTTP response information:

response output in console

Let's try to make our server be able to handle two paths:

fun Application.configureRouting() {
    val users = listOf("John", "Kate", "Mike")

    routing {
        get("/main") {
            call.respond("This is main page")
        }
        get("/resources"){
            call.respond(users)
        }
    }

You should note that we now have two endpoints: two entry points to which the user can make requests and get answers.

But when accessing /resources we will get an error because our server knows nothing about serialization. (We have not installed the appropriate plugin yet, and it is not clear how to return an array of strings to the user.) Let's fix that for now with a standard function:

call.respond(users.joinToString())

Let's make a request:

GET http://localhost/resources

We get the appropriate answer: John, Kate, Mike

We also have functions for other HTTP requests:

post { }
head {  }
put { }
patch { }
delete {  }
options {  }

In this topic, we will deal more with setting the paths for the corresponding HTTP methods rather than how to work with the methods themselves.

This works if we assign different methods to the same path, but the same methods and the same paths are no longer allowed.

get("/main") {
    call.respond("This is main page")
}
post("/main") { }

We don't need to repeat ourselves in writing the general part of the journey. Let's try to fix this situation!

Nested path

With another function of the Route class extension, route(), we can make a common path for nested paths.

routing {
    route("/main") {
        get {
            call.respond("This is main page")
        }
        post { }
    }
}

It may seem that we have omitted the arguments of the "get/post" functions here altogether. Not really. We removed the first argument-path string. The last one, trailing lambda, remains. It handles the request itself and is written inside the {}.

route("/main") {
    get {
        call.respond("This is main page")
    }
    get("/login") {
        call.respond("Please, login!")
    }
}

Now we can request the appropriate path. It is very convenient to test HTTP requests in IDEA files because there is an autocomplete based on the paths we wrote in the code!

autocompletion in test.http requset file

Parameters

The call.parameters field is used to retrieve parameters from the request path.

We have to consider that the required parameter may not be passed.

parameters is just an associative string-string array. Let's get users by their numeric ID. To do this, we need to cast stringId (if it is present at all — so we need to use ?.let) to Int. The conversion can go wrong if it's not a number — again we use ?.let to do the block only if it's not null. In the end, we just return the user if it exists with this ID:

get("/resources/{id}") {
    val users = mapOf(1 to "John", 2 to "Kate", 3 to "Mike")
    call.parameters["id"]?.let { stringId ->
        stringId.toIntOrNull()?.let { id ->
            users[id]?.let { user ->
                call.respondText(user)
            }
        }
    }
}
GET http://localhost/resources/3 # Mike

Now let's try to query with ID 5:

GET http://localhost/resources/5

We get: Response code: 404 (Not Found). Why? In Ktor, if we don't return anything in the endpoint via call.respond* methods, a 404 status will be thrown.

We also have query parameters that are a defined set of parameters attached to the end of a URL, which is listed after the "?" sign. For example, http://localhost/topics?track=kotlin&complexity=3 . & — delimiter for key-value pairs of parameters.

To access them, we need the call.request.queryParameters field. Let's try to rewrite the previous example, but with queryParameters:

get("/resources") {
    val users = mapOf(1 to "John", 2 to "Kate", 3 to "Mike")
    call.request.queryParameters["id"]?.let { stringId ->
    // ...

In fact, everything is similar. We just have a different "source" of data. Now our request to the application will look like this:

GET http://localhost/resources?id=2 # Kate

Keep in mind that these parameters may not exist either. Before using them in your functions, make sure they are not null!

Path patterns

In Ktor we can use patterns to define endpoints. They can apply to specific get, post, delete and so on. Functions as well as the route function are used for grouping, as we already know.

If we write get("/main"), it will be a specific path. We won't get to it with any other queries than main.

What if we want to handle all paths that start with main. Then we try to use the wildcard character *: get("/main/*")

Let's make a request:

GET http://localhost/main/somePath

We get an answer: /main/somePath

To get the path in the response, we used the path() function of the request field.

get("/main/*") {
    call.respondText(call.request.path())
}

Let's try to add two path segments:

GET http://localhost/main/somePath/someAnotherPath

We get it again: Response code: 404 (Not Found). Because by using *, we allow only one next fragment of the path. A fragment is the text of the path between the two "/".

For our query to work well, let's change * to, so-called, tailcard {...}: get("/main/{...}").

Now our query works the way we wanted it to. It handles all paths after /main and we get the response: /main/somePath/someAnotherPath.

Conclusion

We figured out how to work with Routing in Ktor, simple and nested paths, pass parameters, and used wildcards to create more paths that a user can use to access a single endpoint.

19 learners liked this piece of theory. 0 didn't like it. What about you?
Report a typo