Understand everything about routing in Ktor

In this blog, we are going to learn all about Routing in Ktor.

Subscribe to my newsletter and never miss my upcoming articles

This is the third blog in series of Kotlin for Backend. In the previous blog, we got an idea for starting and setting up a Ktor project while we built a couple of routes as well. We also saw GET, POST, and DELETE verbs of REST world as well.

Continuing to the series, we will understand all about routing in Ktor. We will understand,

  • Specifying routes.
  • Understanding route and handling requests/responses.
  • Creating multiple routes.
  • Grouping of routes.
  • Structuring routes.
  • Advanced routing using Location.
  • and a lot more.

Understanding routes is one of the most critical and important features of any backend technology. Let me simplify it for you.

Specifying routes

In this section, we are going to first understand what are routes and what type of routes we can create.

  • /user

    Here, the endpoint is user
  • /user/{userId}

    Here, the userId is the part parameter whose value can we get in the handler like call.parameters["userId"].

But the bigger question is, what are routes?

To simplify the explanation, it is like the URL we call in the browser. Let me give you a very easy example, https://google.com/search is the URL we open in the browser. Where https://google.com is the base URL and /search is the route.

Basically, routes help us differentiate requests within our code and respond to them differently. For example: https://google.com/accounts redirects us to the account page but https://google.com/search redirects us to the search page.

Understanding route and handling requests

In the /users route,

get("/users") {
    // Handle request and send response
 }

We have a route handler where we will handle the request. We can get access to,

  • call.parameters: This is used to handle the path variables in the route.
  • call.request.queryParameters: This is used to handle the query parameters in the route.
  • call.receive: This is the body request with a request model.

And as a response, we have multiple options like:

  • call.respondText: This is used to respond user with text.
  • call.respondHtml: This is used to response HTMLs.
  • call.respond: This is used to response custom models as JSON output.
  • call.respondFile: This is used to response file as output.
  • call.respondRedirect: This is used to redirect to another route.

    Creating multiple routes

    In this section, we are going to understanding how we can create multiple routes in a project. Let me show you a few routes I created in the last review,

    fun Application.configureRouting() {
      install(ContentNegotiation) {
          gson()
      }
    
      routing {
          get("/users") {
              call.respond(users)
          }
          post("/user") {
              val requestBody = call.receive<User>()
              val user = requestBody.copy(id = users.size.plus(1))
              users.add(user)
              call.respond(user)
          }
    
          get("/user") {
              val id = call.request.queryParameters["id"]
              val user = users.find { it.id == id?.toInt() }
              val response = user ?: "User not found"
              call.respond(response)
          }
    
          delete("/user") {
              val id = call.request.queryParameters["id"]
              users.removeIf {
                  it.id == id?.toInt()
              }
              call.respond("User deleted")
          }
      }
    }
    

Here, we have used GET, POST, and DELETE HTTP verbs to create routes. These are in a single file and in the same way, we can create N-number of routes. But if you see above, we are creating multiple routes with /user URL endpoint.

We are going to group them in the next section.

Grouping of Routes

In this section, we are going to group the routes in which we have /user route for GET, POST, and DELETE verbs. To group the multiple routes into one group with a specific path, we use route extension function which looks like:

public fun Route.route(path: String, build: Route.() -> Unit): Route

Now, using the above route extension, we will update our code like:

fun Application.configureRouting() {
    install(ContentNegotiation) {
        gson()
    }

    routing {
        get("/users") {
            call.respond(users)
        }
        route("/user") {
            post {
                val requestBody = call.receive<User>()
                val user = requestBody.copy(id = users.size.plus(1))
                users.add(user)
                call.respond(user)
            }

            get {
                val id = call.request.queryParameters["id"]
                val user = users.find { it.id == id?.toInt() }
                val response = user ?: "User not found"
                call.respond(response)
            }

            delete {
                val id = call.request.queryParameters["id"]
                users.removeIf {
                    it.id == id?.toInt()
                }
                call.respond("User deleted")
            }
        }
    }
}

Here, you can see from the above code block, we have moved all the 3 routes with path /user inside the route extension with the same path.

This will be the same API route like:

 GET http://localhost:8080/user
 POST http://localhost:8080/user
 DELETE http://localhost:8080/user

Similarly, we can group routes into multiple nested routes like:

 route("/v1") {
     route("/user") {
         get {
           call.respond("Hey buddy")
          }
     }
}

This route will transform as:

 GET http://localhost:8080/v1/user

Why we need nested/grouping of routes

We need to group our routes for further development like creating versions of routes for better readability and management. For example:

http://localhost:8080/v1/user
http://localhost:8080/v2/user
http://localhost:8080/v3/user

Structuring of routes

Now, if you see the above code we have defined 4 routes. But it will become a mess if we start adding routes let's say 100 or even 200.

Here, in this section, we are going to learn how we can create a proper structure to manage routes in the project.

The code we have is:

fun Application.configureRouting() {
    install(ContentNegotiation) {
        gson()
    }
    routing {
        get("/users") {
            call.respond(users)
        }
        post("/user") {
            val requestBody = call.receive<User>()
            val user = requestBody.copy(id = users.size.plus(1))
            users.add(user)
            call.respond(user)
        }

        get("/user") {
            val id = call.request.queryParameters["id"]
            val user = users.find { it.id == id?.toInt() }
            val response = user ?: "User not found"
            call.respond(response)
        }

        delete("/user") {
            val id = call.request.queryParameters["id"]
            users.removeIf {
                it.id == id?.toInt()
            }
            call.respond("User deleted")
        }
    }
}

Here, what we can do is we can segregate the routes in individual route extension functions to do their work like:

 fun Application.configureRouting() {
    install(ContentNegotiation) {
        gson()
    }
    routing {
        getAllUsers()
        createUser()
        getIndividualUser()
        deleteUser()
    }
}

You can see that, now the routes are converted to functions that are nothing but just extensions to routes. Now the above functions look like this:

fun Routing.createUser() {
    post("/user") {
        val requestBody = call.receive<User>()
        val user = requestBody.copy(id = users.size.plus(1))
        users.add(user)
        call.respond(user)
    }
}

fun Routing.getAllUsers() {
    get("/users") {
        call.respond(users)
    }
}

fun Routing.getIndividualUser() {
    get("/user") {
        val id = call.request.queryParameters["id"]
        val user = users.find { it.id == id?.toInt() }
        val response = user ?: "User not found"
        call.respond(response)
    }
}

fun Routing.deleteUser() {
    delete("/user") {
        val id = call.request.queryParameters["id"]
        users.removeIf {
            it.id == id?.toInt()
        }
        call.respond("User deleted")
    }
}

This is one of the ways to structure routes. In the real-world application, we will have multiple routes and we need a proper way to define them. Now, I will show my recommended way to structure routes in the application with an example.

Let's say we have features related to User, Post, and Comments in our application. So, we would have routes individually for them as well.

What I would do is, I will create a package called features. Inside that, I will have user, post, and comment packages. These packages have their own routes.

Packages design

I will create a file called, UserRoute, CommentRoute, and PostRoute, and the code will look like this:

// Routes in UserRoute.kt file
fun Application.userRoutes() {
    routing {
     // the user routes
    }
}

// Routes in PostRoute.kt file
fun Application.postsRoute() {
    routing {
      // the post routes
    }
}

// Routes in CommentRoute.kt file
fun Application.commentRoutes() {
    routing {
      // the comment routes
    }
}

So, here you will add the routes here for the required features which you might need for your application.

Since these are the Routes we created for individual features, we need to register them in configureRouting() function like:

fun Application.configureRouting() {
    install(ContentNegotiation) {
        gson()
    }
    routing {
        userRoutes()
        postsRoute()
        commentRoutes()
    }
}

This is how I will segregate routes of the application based on the features I have. And, this is called packaging by feature.

Advanced routing using Location.

Location is a typed version of creating routes. This is used to manage URLs and parameters. No matter if it's for a path variable or query parameters.

To install Location, first, we need to add the dependency:

implementation("io.ktor:ktor-locations:$ktor_version")

and we also need to install it in the main() function in the Application class::

fun Application.main() {
    install(Locations)
}

Let's take a pause here and understand what is Location and how it represents routes in Ktor? As I mentioned, Location is a representation of routes in a typed way which we have to use by class containing parameters required by the URL.

A very basic example of Location is:

get<Users> {
     call.respond(users)
}

Here, the GET is not taking a URL but a class that looks like this:

@Location("/users")
class Users

The above code is making sure that we don't write /users in the GET route rather we use the typed way.

Let's talk about migrating the existing routes to Location. The current code is:

fun Application.configureRouting() {
    install(ContentNegotiation) {
        gson()
    }

    routing {

        get("/users") {
            call.respond(users)
        }

        post("/user") {
            val requestBody = call.receive<User>()
            val user = requestBody.copy(id = users.size.plus(1))
            users.add(user)
            call.respond(user)
        }

        get("/user") {
            val id = call.request.queryParameters["id"]
            val user = users.find { it.id == id?.toInt() }
            val response = user ?: "User not found"
            call.respond(response)
        }

        delete("/user") {
            val id = call.request.queryParameters["id"]
            users.removeIf {
                it.id == id?.toInt()
            }
            call.respond("User deleted")
        }
    }
}

Let us break this down into steps:

  • Creating Location class.
  • Migrating routes to the classes.

Creating Location Class

We will create a Location class called UserLocation. This will handle all the routes /users and /user.

Migrating routes to the classes.

Let's migrate the first route, i.e. /users to the Location feature.

For that, I will create a class called Users like:

@Location("/users")
class Users

Here, Location annotation takes the route, which is /users in our case. This will be used to configure the routing in the project.

Now, let's convert the following code:

get("/users") {
    call.respond(users)
}

This is how it looks with Location:

get<Users> {
     call.respond(users)
}

Here, the Users class is the Location that handles the /users.

Now, let's see how we can handle parameters in Location.

For this, we are going to refactor the following route:

get("/user") {
      val id = call.request.queryParameters["id"]
      val user = users.find { it.id == id?.toInt() }
      val response = user ?: "User not found"
      call.respond(response)
}

Then, we are going to create a Location for /user:

@Location("/user")
data class FindUser(val userId: String)

Here, in the FindUser class, we have userId as a parameter, which we will get as a query parameter.

Now, the updated code becomes like this:

get<FindUser> { request ->
     val id = request.userId
     val user = users.find { it.id == id.toInt() }
     val response = user ?: "User not found"
     call.respond(response)
}

Here, we are getting the request param inside the handler and using that we can get the userId.

Similarly, we can create a Location for URL which has a path variable like: /user/{userId}

Here, userId is in the path. For that Location looks like this:

@Location("/user/{userId}")
data class FindUserById(val userId: String)

If you look closely, FindUser and FindUserById look the same. Where in one case, userId is a query parameter, and in others its the path variable

This is the best way to handle the route if you want to handle it in a typed way.

This is all you need to know for routing in Ktor to start implementing in your project.

If you want to check real implementations of Ktor with the MongoDB project. Click here.

Thank you for reading :) Hope you learned something from my experience.

If you have anything more to discuss, I will be happy to get connected at hi_man_shoe.

See you in the next blog of the series.

No Comments Yet