We have already learned how to configure routing in Ktor. However, when we configure routing in Ktor, we cannot specify the data type for the path parameter in advance. For this, we must carry out additional checks in the code. To do it, we can use the Locations plugin that provides a mechanism to create routes in a typed way for both constructing URLs and reading the parameters.
Sample application
Let's create a sample application that will search for users' hobbies by their ID. First, we define data classes to store data:
data class User(
val name: String,
val age: Int,
val hobbies: List<String>
)Next, we create our users to fill the "database":
fun Application.configureRouting() {
val users = listOf(
User("Alice", 23, listOf("art", "meditation")),
User("Bob", 30, listOf("programming")),
User("Kate", 19, listOf("blogging", "chess")),
)
routing { ... }
}Create extension functions for PipelineContext<Unit, ApplicationCall> (the object we interact with inside the get function) to get User and Hobby to make our code reusable:
suspend fun PipelineContext<Unit, ApplicationCall>.getUserOrNull(userId: Int): User? {
users.getOrNull(userId)?.apply { return this }
call.respond("User not found")
return null
}
suspend fun PipelineContext<Unit, ApplicationCall>.getHobbyOrNull(user: User, hobbyId: Int): Hobby? {
user.hobbies.getOrNull(hobbyId)?.apply { return this }
call.respond("Hobby not found")
return null
}Finally, configure the routing for our application:
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
configureRouting()
}.start(wait = true)
}
fun Application.configureRouting() {
val users = listOf( ... )
routing {
get("users/") { call.respond("There are ${users.size} users in the database") }
get("users/{userId}/") {
val userId = call.parameters["userId"]?.toIntOrNull() ?: return@get
if (userId < 0 || userId > users.lastIndex) {
call.respond("User not found")
return@get
}
val user = users[userId]
call.respond("${user.name} is ${user.age} years old and has ${user.hobbies.size} hobbies")
}
get("users/{userId}/{hobbyId}/") {
val userId = call.parameters["userId"]?.toIntOrNull() ?: return@get
val hobbyId = call.parameters["hobbyId"]?.toIntOrNull() ?: return@get
if (userId < 0 || userId > users.lastIndex) {
call.respond("User not found")
return@get
}
val user = users[userId]
if (hobbyId < 0 || hobbyId > user.hobbies.lastIndex) {
call.respond("Hobby not found")
return@get
}
val hobby = user.hobbies[hobbyId]
call.respond("${user.name} enjoys $hobby")
}
}
}We have to perform the same checks for the correctness of data types in nested routes; the third way performs the same checks as the second. Our code grows as the nesting increases. As a result, the app won't be easy to maintain.
Installation
To use Locations, you need to include the ktor-server-locations artifact to the build script:
implementation("io.ktor:ktor-server-locations:$ktor_version")Pass the plugin to the install function in the application main function:
fun main() {
embeddedServer(Netty, port = 8080) {
install(Locations)
}.start(wait = true)
}Define route classes and handlers
For each typed route you want to handle, create a data class with the parameters that you want to take. By default, you can use Int, Long, Float, Double, Boolean, String, Enum, and Iterable as parameters. You can also create a regular class without a constructor if there are no parameters in it. That class must be annotated with @Location specifying a path just like you did in the normal routing:
@Location("library/{author}/{year}/")
data class Books(val author: String, val year: Int)
The names between the curly braces must match the properties of the class.
Once you have defined the classes annotated with @Location, this plugin artifact exposes new typed methods for defining route handlers: get, options, header, post, put, delete and patch:
get<Books> { books ->
call.respondText("Looking for books by ${books.author} written in ${books.year}")
}Updating the code
Now, let's rewrite the application routing using the Locations plugin. Let's define the locations:
@Location("users/")
class Listing
@Location("users/{userId}/")
data class UserInfo(val userId: Int)
@Location("users/{userId}/{hobbyId}/")
data class HobbyInfo(val userId: Int, val hobbyId: Int)The updated routing will look like this:
routing {
get<Listing> { call.respond("There are ${users.size} users in the database") }
get<UserInfo> { userInfo ->
if (userInfo.userId < 0 || userInfo.userId > users.lastIndex) {
call.respond("User not found")
return@get
}
val user = users[userInfo.userId]
call.respond("${user.name} is ${user.age} years old and has ${user.hobbies.size} hobbies")
}
get<HobbyInfo> { hobbyInfo ->
if (hobbyInfo.userId < 0 || hobbyInfo.userId > users.lastIndex) {
call.respond("User not found")
return@get
}
val user = users[hobbyInfo.userId]
if (hobbyInfo.hobbyId < 0 || hobbyInfo.hobbyId > user.hobbies.lastIndex) {
call.respond("Hobby not found")
return@get
}
val hobby = user.hobbies[hobbyInfo.hobbyId]
call.respond("${user.name} enjoys $hobby")
}
}As you can see, the code has become much smaller. All we need to do is check for the existence of an element by index. You can be sure of the correctness of the entered data.
If the user enters data that does not match the required types, the application throws a ParameterConversionException exception:
Nested locations
It's easy to see that we can still improve our code: it repeats the routes prefixes, for example /users. The Locations plugin allows you to create nested routes by defining these routes inside the parent data class. To get the parameters defined in the superior locations, include those property names in your classes for the internal routes. For example:
@Location("users/")
class Listing {
@Location("{userId}/")
data class UserInfo(val parent: Listing, val userId: Int) {
@Location("{hobbyId}/")
data class HobbyInfo(val parent: UserInfo, val hobbyId: Int)
}
}Now the routing will look like this:
routing {
get<Listing> { ... }
get<Listing.UserInfo> { ... }
get<Listing.UserInfo.HobbyInfo> { ... }
}Build URLs
Another major advantage of using the plugin is the ability to build URLs. With them, you don't need to create hard-coded paths, as you can change them without changing the code that accesses them.
You can construct URLs to your routes by calling application.locations.href with an instance of a class annotated with @Location:
val href = application.locations.href(Listing.UserInfo(Listing(), userId = 0))
println(href) // "/users/0"Conclusion
We have figured out how to create routes in a typed way, built URLs, and created nested locations. Using the Locations plugin, you do not need to check the types of parameters passed by the user. In addition, your code becomes clearer and cleaner.