We are already familiar with running a simple Ktor application. Now it's time to figure out different approaches to handling requests and how theses mechanics are arranged in Ktor using 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. In modern Ktor applications, we typically define our logic in modules, which are extension functions of the Application class.
Let's look at a sample code for a routing configuration:
fun Application.configureRouting() {
routing {
get("/") {
call.respondText("Hello World!")
}
}
}We can call this on a main module function which we can then pass as a reference to embeddedServer or specify it in a (YAML, HOCON) configuration file.
Here, configureRouting is an extension function of the Application class. Inside this function, we define the logic for handling HTTP requests.
routing is a convenient helper function. Under the hood, it installs the Routing plugin into your application. It takes a lambda with a receiver, specifically Routing. This allows us to write code inside the curly braces (trailing lambda) as if we were inside Routing itself.
Inside the routing block, we use functions like get, post, put, and others. These are extension functions of the Route interface from which Routing inherits from.
What is the call object?
get("/") {
call.respondText("Hello World!")
}You might wonder where call comes from. The lambda passed to the get function also has a receiver (the routing context). call is a property of this context. It represents the ApplicationCall and is responsible for handling the incoming request and sending the response. With the help of the call object, we can access information about the received request (such as parameters and headers) and send data back to the client.
Thanks to Kotlin features like trailing lambdas, lambdas with receivers, and extension functions, Ktor achieves a concise and readable DSL (Domain Specific Language) to describe our routing logic.
Determining routes
Before we return something for specific routes, let's see how to return simple content using the 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, you can specify contentType and status parameters — which are, in fact, directly related to the HTTP protocol. We will discuss these parameters in more detail in further topics!
respondText is just a specialized version of the generic respond function. While respond is used for sending various types of objects (which often require serialization plugins), respondText is perfect for simple strings:
call.respondText("Hello World!")In the browser, you will see the page displaying "Hello World!":
To see the response details in Google Chrome, right-click on the page and choose Inspect, then navigate to the Network tab and refresh the page. Your browser intercept your network activity and now you see the info about the HTTP request.
Also, we can test the requests in IntelliJ IDEA Ultimate Edition. We can create a file with a .http extension to write our requests:
Write the following query and click on the green "Run" button (make sure your server is running):
In the console you will see a response along with additional HTTP response headers:
Let's try to make our server handle two different paths:
import io.ktor.server.response.respond
// ...
fun Application.configureRouting() {
val users = listOf("John", "Kate", "Mike")
routing {
get("/main") {
call.respondText("This is main page")
}
get("/resources"){
call.respond(users)
}
}
}Note that we now have two endpoints: two entry points to which the user can make requests and get responses.
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()) // You might as well use respondTextMaking a request:
GET http://localhost/resourcesWe get the appropriate answer: John, Kate, Mike
We also have functions for other HTTP methods:
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 for the same method and the same path, the first one will be used.
get("/main") {
call.respond("This is main page")
}
post("/main") { }Nested path
We don't need to repeat ourselves when writing the common parts of a path. With the route() function, we can group nested paths together.
routing {
route("/main") {
get {
call.respondText("This is main page")
}
get("/login") {
call.respond("Please, login!")
}
post { }
}
}In the first get inside the route, we omitted the path argument. This means it handles the base path of the route (/main) similar to post. The second get appends /login to the base path, creating a handler for /main/login.
It is very convenient to test these HTTP requests in IntelliJ IDEA .http files because the IDE provides autocomplete based on the paths we wrote in the code!
Parameters
The call.parameters property is used to retrieve path parameters from the URL.
A path parameter is defined in the route path using curly braces, for example, {id}. Let's try to retrieve a user by their numeric ID. Since parameters are always strings, we need to convert the ID to an Int safely.
get("/resources/{id}") {
val users = mapOf(1 to "John", 2 to "Kate", 3 to "Mike")
// Retrieve the parameter
val idString = call.parameters["id"]
// Safely convert to Int and find user
idString?toIntOrNull()?.let { id ->
users[id]?.let { user ->
call.respondText(user)
return@get // End the handler here if found
}
}
// If we reach here, the user wasn't found or ID was invalid
call.respondText("User not found", status = HttpStatusCode.NotFound)
}GET http://localhost/resources/3 # MikeNow let's try to query with ID 5:
GET http://localhost/resources/5 # User not found (404)Note that if we don't respond when handling a call, Ktor will respond with a Not Found 404 status.
We also have query parameters that are attached to the end of a URL after a "?" sign. For example, http://localhost/topics?track=kotlin&complexity=3 . & is used to separate the parameters.
To access them, we use call.request.queryParameters. Let's try to rewrite the previous example to use a query parameter instead of a path parameter:
get("/resources") {
val users = mapOf(1 to "John", 2 to "Kate", 3 to "Mike")
val idString = call.request.queryParameters["id"]
// The logic remains similar
}Now our request will look like this:
GET http://localhost/resources?id=2 # KateKeep 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 specific patterns to define endpoints.
If we write get("/main"), it will be a specific path. We won't get to it with any other queries than main.
If we want to handle all paths that start with "main", We can use the wildcard character (*): get("/main/*"). This matches exactly one path segment.
get("/main/*") {
call.respondText(call.request.path())
}Let's make a request:
GET http://localhost/main/somePathWe get an answer: /main/somePath. To get the path in the response, we used the path() function of the request property.
If we try to add two path segments:
GET http://localhost/main/somePath/someOtherPathWe get a 404 error. For our query to work, let's change * to a tailcard {...}: get("/main/{...}").
get("/main/{...}") {
call.respondText(call.request.path())
}Now our query handles arbitrarily deep paths and we get the response: /main/somePath/someOtherPath.
Conclusion
We figured out how to work with Routing in Ktor, simple and nested paths, passing parameters, and using wildcards to create flexible endpoints that a user can use to access our application.