Skip to Content
Course content

117: Ktor Routing In Depth

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Routing is where Ktor really shows its flexibility, but if you're coming from a framework like Spring, you might expect a more declarative, annotation-based approach. Ktor does things differently. It uses a DSL that essentially builds a routing tree. Once you understand that tree structure, you can do some pretty sophisticated things with your URL patterns.

To get our hands dirty, we're going to build a small API for a Movie Catalog. We aren't just doing a simple list; we want to handle specific movie lookups, nested reviews, and some filtering. I've found that the best way to master Ktor routing is to see how the nesting actually behaves in practice.

Setting up the Movie Base

First, let's start with the basic collection. I'll wrap everything in a routing block. I like to group related endpoints using the route() function. It keeps the code clean and allows us to apply specific plugins (like authentication) to a whole group of endpoints later on without repeating ourselves.

routing {
    route("/movies") {
        get {
            call.respondText("Here is the full list of movies!")
        }
    }
}

Capturing IDs with Path Parameters

Now, we need to be able to fetch a single movie. In Ktor, we use curly braces to define a path parameter. I'll add this inside our /movies route. This means the actual path becomes /movies/{id}.

route("/movies") {
    get {
        call.respondText("Here is the full list of movies!")
    }
    get("{id}") {
        val id = call.parameters["id"] ?: return@get call.respondText("Missing ID", status = HttpStatusCode.BadRequest)
        call.respondText("Fetching details for movie: $id")
    }
}

Note how I used return@get. Since we're inside a lambda, a standard return would try to return from the entire function containing the routing block, which isn't what we want. We just want to exit the current handler.

Nesting Reviews Under Movies

This is where the "tree" concept becomes powerful. A movie has reviews. It makes semantic sense for the URL to look like /movies/123/reviews. Instead of writing a flat string for that route, I'm going to nest another route block inside the {id} block.

route("/movies") {
    get { /* ... list movies ... */ }
    
    route("{id}") {
        get {
            val id = call.parameters["id"]
            call.respondText("Details for movie $id")
        }
        
        route("/reviews") {
            get {
                val id = call.parameters["id"]
                call.respondText("Showing reviews for movie $id")
            }
        }
    }
}

Because the /reviews route is nested inside {id}, it automatically inherits that path parameter. I can access id inside the reviews handler without having to redefine it.

The Shadowing Trap

I've made this mistake more times than I'd like to admit. Let's say I want a special endpoint to get the "featured" movie of the week. I'll add a route for /movies/featured.

route("/movies") {
    get("{id}") { 
        call.respondText("Movie ID: ${call.parameters["id"]}") 
    }
    get("featured") { 
        call.respondText("This is the featured movie!") 
    }
}

If I run this and hit /movies/featured, Ktor is going to tell me "Movie ID: featured". Why? Because the {id} route is a wildcard that matches anything that comes after /movies/. Since I defined it first, Ktor matches it and never even looks at the featured route.

To fix this, I have to move the most specific routes to the top. The routing tree is evaluated in the order it's defined. I'll swap them:

route("/movies") {
    get("featured") { 
        call.respondText("This is the featured movie!") 
    }
    get("{id}") { 
        call.respondText("Movie ID: ${call.parameters["id"]}") 
    }
}

Now, Ktor checks if the path is exactly "featured" first. If it isn't, it falls back to the {id} parameter. Much better.

Filtering with Query Parameters

Finally, let's handle filtering. Unlike path parameters, query parameters (the stuff after the ? in a URL) aren't part of the route definition itself. They are handled inside the handler using call.request.queryParameters.

I'll update our main movies list to filter by genre if the user provides one:

get {
    val genre = call.request.queryParameters["genre"]
    if (genre != null) {
        call.respondText("Filtering movies by genre: $genre")
    } else {
        call.respondText("Here is the full list of movies!")
    }
}

Now, a request to /movies?genre=SciFi works perfectly, while /movies still gives the full list.




📋 Practical Task

Build a Nested "Actor" API

Expand the Movie Catalog project by adding a new routing section for Actors. Your implementation must meet the following requirements:

  • Create a base route /actors.
  • Implement a GET request to /actors/top that returns a hardcoded string listing the top actors.
  • Implement a GET request to /actors/{name} that returns the name of the actor from the path parameter.
  • Inside the {name} route, nest a further route /movies. A GET request to /actors/{name}/movies should return a string stating: "Listing all movies for actor [name]".

Crucial: Ensure your routes are ordered correctly so that the /top endpoint is not "swallowed" by the /{name} parameter.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.