Search code examples
kotlinurl-routingktor

What's the correct way to nest Ktor route matchers and handlers for a typical REST implementation?


I'm having a bit of trouble understanding the proper way to use Ktor's DSL for request routing. The problem is that when I test my API and try to GET /nomenclature/articles/categories which should return a list of all article categories, I get instead Invalid article specified which a message I return for the /nomenclature/articles/{articleId} route when the articleId parameter is invalid.

Here's my code:

route("/nomenclature") {
    method(HttpMethod.Get) {
        handle { call.respondText("The resource you accessed is not a valid REST resource, but a parent node. Children nodes include articles, categories, subcategories and so on.") }
    }
    route("articles") {
        route("categories") {
            get("{categoryId?}") {
                val categoryIdParam = call.parameters["categoryId"]
                if (categoryIdParam != null) {
                    val categoryId = categoryIdParam.toIntOrNull()
                    if (categoryId != null) {
                        val category = articlesDAO.findCategoryById(categoryId)
                        if (category != null) call.respond(category)
                        else call.respondText("Category not found", status = HttpStatusCode.NotFound)
                    } else call.respondText("Invalid category ID specified", status = HttpStatusCode.BadRequest)
                } else {
                    val categories = articlesDAO.getCategories()
                    if (categories != null) call.respond(categories)
                    else call.respondText("No categories found", status = HttpStatusCode.NotFound)
                }
            }
        }
        route("subcategories") {
            get("{subcategoryId?}") {
                val subcategoryIdParam = call.parameters["subcategoryId"]
                if (subcategoryIdParam != null) {
                    val subcategoryId = subcategoryIdParam.toIntOrNull()
                    if (subcategoryId != null) {
                        val subcategory = articlesDAO.findSubcategoryById(subcategoryId)
                        if (subcategory != null) call.respond(subcategory)
                        else call.respondText("Subcategory not found", status = HttpStatusCode.NotFound)
                    } else call.respondText("Invalid subcategory ID specified", status = HttpStatusCode.BadRequest)
                } else {
                    val subcategories = articlesDAO.getCategories()
                    if (subcategories != null) call.respond(subcategories)
                    else call.respondText("No subcategories found", status = HttpStatusCode.NotFound)
                }
            }
        }
        get("types") {
            val articleTypes = articlesDAO.getArticleTypes()
            if (articleTypes != null) call.respond(articleTypes)
            else call.respondText("No article types found", status = HttpStatusCode.NotFound)
        }
        get("wholePackagings") {
            val wholePackagings = articlesDAO.getWholePackagings()
            if (wholePackagings != null) call.respond(wholePackagings)
            else call.respondText("No whole packagings found", status = HttpStatusCode.NotFound)
        }
        get("fractionsPackagings") {
            val fractionsPackagings = articlesDAO.getFractionPackagings()
            if (fractionsPackagings != null) call.respond(fractionsPackagings)
            else call.respondText("No fractions packagings found", status = HttpStatusCode.NotFound)
        }
        get("{articleId?}") {
            val articleIdParam = call.parameters["articleId"]
            if (articleIdParam != null) {
                val articleId = articleIdParam.toIntOrNull()
                if (articleId != null) {
                    val article = articlesDAO.findArticleById(articleId)
                    if (article != null) call.respond(article) else call.respondText("No article found", status = HttpStatusCode.NotFound)
                } else call.respondText("Invalid article ID specified", status = HttpStatusCode.BadRequest)
            } else {
                val articles = articlesDAO.getArticles()
                if (articles != null) call.respond(articles) else call.respondText("No articles found", status = HttpStatusCode.NotFound)
            }
        }
    }
}

It would be really great if someone could help me by providing a basic but somewhat comprehensive example of how to use all Ktor route matchers and handlers, including in a nested resource manner.

EDIT: I've rewritten the routing using a different approach but I would still like to know why my previous version didn't work as expected. Here's my second approach that seems to work as expected (I've tested it):

routing {
    route("/v1") {
        route("stock") {
            get {
                val stock = stockDAO.getAllStock()
                if (stock != null) call.respond(stock) else call.respondText("No stock found", status = HttpStatusCode.NotFound)
            }
            get("{locationId}") {
                val locationIdParam = call.parameters["locationId"]
                if (locationIdParam != null) {
                    val locationId = locationIdParam.toIntOrNull()
                    if (locationId != null) {
                        val stock = stockDAO.getStockByLocationId(locationId)
                        if (stock != null) call.respond(stock) else call.respondText("No stock found", status = HttpStatusCode.NotFound)
                    } else call.respondText("ERROR: Invalid location ID specified.", status = HttpStatusCode.BadRequest)
                }
            }
        }

        route("nomenclature") {
            get {
                call.respondText("The resource you accessed is not a valid REST resource, but a parent node. Children nodes include articles, categories, subcategories and so on.")
            }

            route("articles") {
                get {
                    val articles = articlesDAO.getArticles()
                    if (articles != null) call.respond(articles) else call.respondText("No articles found", status = HttpStatusCode.NotFound)
                }
                get("{articleId}") {
                    val articleIdParam = call.parameters["articleId"]
                    if (articleIdParam != null) {
                        val articleId = articleIdParam.toIntOrNull()
                        if (articleId != null) {
                            val article = articlesDAO.findArticleById(articleId)
                            if (article != null) call.respond(article) else call.respondText("No article found", status = HttpStatusCode.NotFound)
                        } else call.respondText("Invalid article ID specified", status = HttpStatusCode.BadRequest)
                    }
                }

                route("categories") {
                    get {
                        val categories = articlesDAO.getCategories()
                        if (categories != null) call.respond(categories)
                        else call.respondText("No categories found", status = HttpStatusCode.NotFound)
                    }
                    get("{categoryId}") {
                        val categoryIdParam = call.parameters["categoryId"]
                        if (categoryIdParam != null) {
                            val categoryId = categoryIdParam.toIntOrNull()
                            if (categoryId != null) {
                                val category = articlesDAO.findCategoryById(categoryId)
                                if (category != null) call.respond(category)
                                else call.respondText("Category not found", status = HttpStatusCode.NotFound)
                            } else call.respondText("Invalid category ID specified", status = HttpStatusCode.BadRequest)
                        }
                    }
                }

                route("subcategories") {
                    get {
                        val subcategories = articlesDAO.getSubcategories()
                        if (subcategories != null) call.respond(subcategories)
                        else call.respondText("No subcategories found", status = HttpStatusCode.NotFound)
                    }
                    get("{subcategoryId}") {
                        val subcategoryIdParam = call.parameters["subcategoryId"]
                        if (subcategoryIdParam != null) {
                            val subcategoryId = subcategoryIdParam.toIntOrNull()
                            if (subcategoryId != null) {
                                val subcategory = articlesDAO.findSubcategoryById(subcategoryId)
                                if (subcategory != null) call.respond(subcategory)
                                else call.respondText("Subcategory not found", status = HttpStatusCode.NotFound)
                            } else call.respondText("Invalid subcategory ID specified", status = HttpStatusCode.BadRequest)
                        }
                    }
                }

                get("types") {
                    val articleTypes = articlesDAO.getArticleTypes()
                    if (articleTypes != null) call.respond(articleTypes)
                    else call.respondText("No article types found", status = HttpStatusCode.NotFound)
                }
                get("wholePackagings") {
                    val wholePackagings = articlesDAO.getWholePackagings()
                    if (wholePackagings != null) call.respond(wholePackagings)
                    else call.respondText("No whole packagings found", status = HttpStatusCode.NotFound)
                }
                get("fractionsPackagings") {
                    val fractionsPackagings = articlesDAO.getFractionPackagings()
                    if (fractionsPackagings != null) call.respond(fractionsPackagings)
                    else call.respondText("No fractions packagings found", status = HttpStatusCode.NotFound)
                }
            }
        }
    }
}

Solution

  • The reason is that you haven't specified any handler for GET /nomenclature/articles/categories.

    You set up a route for /articles, and then for /categories, but there is no handler inside. The only thing inside is get("{categoryId?}"), which doesn't match, since there is no tailcard.

    The reason for getting /nomenclature/articles/{articleId} is, that it first tries to match /articles, but since it cannot match /categories (there is no handler), it goes on looking and finally finds the last route, which contains a wildcard parameter and matches.

    If you want to set up a handler for that specific route, here is how:

    route("articles") {
        route("categories") {
            get("{categoryId?}") {
                ...
            }
            get {
                ... your code ...
            }
        }
    }