Type-Safe Routing DSL (Mini-Ktor)
Build a type-safe routing DSL that mimics Ktor’s routing { } block — the kind
of API where nested route("/api/v1") { ... } calls accumulate path prefixes,
get/post/etc. register handlers, and use(middleware) wraps everything. The
point is to learn how Kotlin’s lambda-with-receiver and @DslMarker turn
ordinary function calls into a clean, statically-typed configuration language.
The finished DSL lets you write this:
val router = router { get("/health") { respond(200, "OK") }
route("/api/v1") { // Middleware applied to all routes in this block use(loggingMiddleware) use(authMiddleware)
get("/users") { val users = listOf("Alice", "Bob", "Charlie") respondJson(200, users) }
get("/users/{id}") { val id = pathParam("id") respondJson(200, mapOf("id" to id, "name" to "User $id")) }
post("/users") { val body = requestBody() respondJson(201, mapOf("created" to body)) }
route("/admin") { use(adminOnlyMiddleware)
delete("/users/{id}") { val id = pathParam("id") respond(204, "") } } }}Requirements
Section titled “Requirements”- A
@DslMarkerannotation to prevent scope leakage between nested blocks. - Route nesting with path-prefix accumulation (so the inner
deleteabove resolves to/api/v1/admin/users/{id}). - HTTP method handlers:
get,post,put,delete,patch. - Path parameters extracted from
{param}placeholders. - Middleware support via
use()— middleware functions wrap handlers. - A request/response context exposing
respond(),respondJson(),pathParam(), andrequestBody(). - Route matching — given a method + path, find and execute the matching handler.
If you’ve used Express in TS (app.use(...), router.get(...)) or Go’s
http.ServeMux / chi, this is the same idea — but Kotlin lets the route tree be a
nested block of typed builder calls instead of a flat list of registrations.
The worked solution
Section titled “The worked solution”A single Gradle module. One file defines the whole DSL; Main.kt exercises it.
Directoryrouting-dsl/
- build.gradle.kts deps + build config
- settings.gradle.kts project name
Directorysrc/
Directorymain/kotlin/com/example/dsl/
- RoutingDsl.kt the DSL: builders, context, router, middleware
- Main.kt demo wiring + simulated requests
Directorytest/kotlin/com/example/dsl/
- RoutingDslTest.kt behavior tests
build.gradle.kts
Section titled “build.gradle.kts”A plain Kotlin/JVM application. The only runtime dependency is
kotlinx-serialization-json, used by respondJson to turn any value into a JSON
body.
plugins { kotlin("jvm") version "2.0.0" kotlin("plugin.serialization") version "2.0.0" application}
group = "com.example"version = "1.0.0"
repositories { mavenCentral()}
dependencies { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
testImplementation(kotlin("test")) testImplementation("org.junit.jupiter:junit-jupiter:5.10.3")}
application { mainClass.set("com.example.dsl.MainKt")}
tasks.test { useJUnitPlatform()}
kotlin { jvmToolchain(21)}The DSL core: RoutingDsl.kt
Section titled “The DSL core: RoutingDsl.kt”This is the whole engine. We’ll walk it top to bottom — it reads in four layers: the marker + HTTP types, the request context, the route builder, and the router that matches and dispatches.
1. The @DslMarker and HTTP types
Section titled “1. The @DslMarker and HTTP types”@DslMarker is the key trick. Annotate your own annotation with it, then put that
annotation on every DSL scope class (RequestContext, RouteBuilder). Now Kotlin
forbids implicit access to an outer receiver from an inner block — so inside a
nested route("/admin") { ... } you can’t accidentally call the outer builder’s
get(...) without qualifying it. That’s what stops the “scope leakage” the
requirements mention.
package com.example.dsl
import kotlinx.serialization.encodeToStringimport kotlinx.serialization.json.Json
// --- DSL Marker ---
@DslMarkerannotation class RoutingDsl
// --- HTTP Types ---
enum class HttpMethod { GET, POST, PUT, DELETE, PATCH }
data class HttpRequest( val method: HttpMethod, val path: String, val body: String = "", val headers: Map<String, String> = emptyMap())
data class HttpResponse( val statusCode: Int, val body: String, val headers: Map<String, String> = emptyMap())2. The request context — what this is inside a handler
Section titled “2. The request context — what this is inside a handler”Every handler lambda runs with a RequestContext as its receiver. That’s why,
inside get("/x") { ... }, you can just call pathParam("id") or respond(...)
without a qualifier — they’re methods on the implicit this. Note respondJson
is inline fun <reified T>: the reified type parameter lets
Json.encodeToString(data) serialize any T without you passing a serializer
explicitly. The response property has a private set, so handlers mutate it only
through respond / respondJson.
@RoutingDslclass RequestContext( val request: HttpRequest, val pathParams: Map<String, String> = emptyMap()) { var response: HttpResponse = HttpResponse(200, "") private set
fun pathParam(name: String): String = pathParams[name] ?: error("Path parameter '$name' not found")
fun requestBody(): String = request.body
fun header(name: String): String? = request.headers[name]
fun respond(statusCode: Int, body: String) { response = HttpResponse(statusCode, body) }
inline fun <reified T> respondJson(statusCode: Int, data: T) { val json = Json.encodeToString(data) response = HttpResponse( statusCode = statusCode, body = json, headers = mapOf("Content-Type" to "application/json") ) }}Two type aliases name the two function shapes the DSL is built from. A Middleware
takes the context plus a next continuation (call it to proceed, skip it to
short-circuit). A Handler is a RequestContext.() -> Unit — a
lambda-with-receiver, which is exactly what makes respond(...) callable
unqualified inside the block.
typealias Middleware = (RequestContext, () -> Unit) -> Unittypealias Handler = RequestContext.() -> Unit
data class RouteEntry( val method: HttpMethod, val pathPattern: String, val handler: Handler, val middleware: List<Middleware>)3. The route builder — how the DSL composes routes
Section titled “3. The route builder — how the DSL composes routes”RouteBuilder is the receiver of the router { ... } and route(...) { ... }
blocks. The get/post/put/delete/patch methods each take a path plus a
Handler and register a RouteEntry. The interesting one is route: it takes a
block: RouteBuilder.() -> Unit, creates a child builder seeded with the
accumulated pathPrefix and the parent’s middleware, runs the block against it
with nestedBuilder.block(), and folds the child’s routes back up. That recursion
is what produces the flattened /api/v1/admin/users/{id} paths and the additive
middleware lists.
@RoutingDslclass RouteBuilder( private val pathPrefix: String = "", private val parentMiddleware: List<Middleware> = emptyList()) { private val routes = mutableListOf<RouteEntry>() private val middleware = mutableListOf<Middleware>()
fun use(mw: Middleware) { middleware.add(mw) }
fun get(path: String, handler: Handler) = addRoute(HttpMethod.GET, path, handler) fun post(path: String, handler: Handler) = addRoute(HttpMethod.POST, path, handler) fun put(path: String, handler: Handler) = addRoute(HttpMethod.PUT, path, handler) fun delete(path: String, handler: Handler) = addRoute(HttpMethod.DELETE, path, handler) fun patch(path: String, handler: Handler) = addRoute(HttpMethod.PATCH, path, handler)
fun route(path: String, block: RouteBuilder.() -> Unit) { val nestedBuilder = RouteBuilder( pathPrefix = pathPrefix + normalizePath(path), parentMiddleware = parentMiddleware + middleware ) nestedBuilder.block() routes.addAll(nestedBuilder.build()) }
private fun addRoute(method: HttpMethod, path: String, handler: Handler) { val fullPath = pathPrefix + normalizePath(path) routes.add(RouteEntry(method, fullPath, handler, parentMiddleware + middleware)) }
internal fun build(): List<RouteEntry> = routes.toList()
private fun normalizePath(path: String): String { val trimmed = path.trimEnd('/') return if (trimmed.startsWith("/")) trimmed else "/$trimmed" }}4. The router — matching and the middleware chain
Section titled “4. The router — matching and the middleware chain”router { } is the top-level entry point: it builds a root RouteBuilder, runs
your block against it, and wraps the resulting flat list in a Router. The
Router.handle method finds a matching entry, builds a RequestContext, then
assembles the middleware chain.
That chain is the elegant part. entry.middleware.foldRight(...) starts from the
innermost function { entry.handler(context) } and wraps it outward: each
middleware becomes { mw(context, next) }, where next is everything inside it.
Calling chain() runs the outermost middleware first; each one decides whether to
call next() (proceed) or not (short-circuit, e.g. an auth failure).
class Router(private val routes: List<RouteEntry>) {
fun handle(request: HttpRequest): HttpResponse { val matchResult = findRoute(request.method, request.path) ?: return HttpResponse(404, """{"error":"Not Found","path":"${request.path}"}""")
val (entry, pathParams) = matchResult val context = RequestContext(request, pathParams)
// Build the middleware chain val chain = entry.middleware.foldRight({ entry.handler(context) }) { mw, next -> { mw(context, next) } }
chain() return context.response }
private fun findRoute(method: HttpMethod, path: String): Pair<RouteEntry, Map<String, String>>? { val normalizedPath = path.trimEnd('/') for (entry in routes) { if (entry.method != method) continue val params = matchPath(entry.pathPattern, normalizedPath) if (params != null) return entry to params } return null }
/** * Match a path pattern like "/users/{id}/posts/{postId}" against * an actual path like "/users/42/posts/7". * Returns extracted parameters or null if no match. */ private fun matchPath(pattern: String, path: String): Map<String, String>? { val patternParts = pattern.split("/").filter { it.isNotEmpty() } val pathParts = path.split("/").filter { it.isNotEmpty() }
if (patternParts.size != pathParts.size) return null
val params = mutableMapOf<String, String>() for ((patternPart, pathPart) in patternParts.zip(pathParts)) { if (patternPart.startsWith("{") && patternPart.endsWith("}")) { val paramName = patternPart.substring(1, patternPart.length - 1) params[paramName] = pathPart } else if (patternPart != pathPart) { return null } } return params }
fun printRoutes() { println("Registered routes:") routes.forEach { entry -> val mwCount = entry.middleware.size val mwInfo = if (mwCount > 0) " [$mwCount middleware]" else "" println(" ${entry.method.name.padEnd(6)} ${entry.pathPattern}$mwInfo") } }}
// --- Top-level DSL entry point ---
fun router(block: RouteBuilder.() -> Unit): Router { val builder = RouteBuilder() builder.block() return Router(builder.build())}Path matching is deliberately simple: split both pattern and path on /, compare
segment by segment, and treat any {name} segment as a wildcard that captures into
the params map. Different segment counts mean no match — there’s no greedy wildcard.
5. Built-in middleware
Section titled “5. Built-in middleware”Three sample middleware show the pattern. loggingMiddleware and
timingMiddleware are plain Middleware values that call next() and do work
before/after. authMiddleware is a factory — a function returning a
Middleware closure over its validToken — and it short-circuits by calling
ctx.respond(401, ...) and not calling next().
val loggingMiddleware: Middleware = { ctx, next -> println("[LOG] ${ctx.request.method} ${ctx.request.path}") next() println("[LOG] -> ${ctx.response.statusCode}")}
val timingMiddleware: Middleware = { ctx, next -> val start = System.nanoTime() next() val elapsed = (System.nanoTime() - start) / 1_000_000.0 println("[TIMING] ${ctx.request.method} ${ctx.request.path} took ${elapsed}ms")}
fun authMiddleware(validToken: String): Middleware = { ctx, next -> val token = ctx.header("Authorization") if (token == "Bearer $validToken") { next() } else { ctx.respond(401, """{"error":"Unauthorized"}""") }}Wiring it together: Main.kt
Section titled “Wiring it together: Main.kt”Main.kt builds a small app with the DSL, prints the registered routes, then fires
a series of simulated HttpRequests through app.handle(...). Notice how the
nested route("/admin") inside route("/api/v1") inherits both the logging and
timing middleware and adds auth on top.
fun main() { // Build the router using the DSL val app = router { get("/health") { respond(200, "OK") }
route("/api/v1") { use(loggingMiddleware) use(timingMiddleware)
get("/users") { respondJson(200, listOf("Alice", "Bob", "Charlie")) }
get("/users/{id}") { val id = pathParam("id") respondJson(200, mapOf("id" to id, "name" to "User $id")) }
post("/users") { val body = requestBody() respondJson(201, mapOf("created" to true, "body" to body)) }
route("/admin") { use(authMiddleware("secret-token"))
delete("/users/{id}") { val id = pathParam("id") respond(204, "") println("Deleted user $id") }
get("/stats") { respondJson(200, mapOf( "totalUsers" to 42, "activeUsers" to 37 )) } } } }
// Print registered routes app.printRoutes() println()
// Simulate requests println("=== GET /health ===") val r1 = app.handle(HttpRequest(HttpMethod.GET, "/health")) println("Response: ${r1.statusCode} ${r1.body}") println()
println("=== GET /api/v1/users/42 ===") val r3 = app.handle(HttpRequest(HttpMethod.GET, "/api/v1/users/42")) println("Response: ${r3.statusCode} ${r3.body}") println()
println("=== DELETE /api/v1/admin/users/5 (no auth) ===") val r5 = app.handle(HttpRequest(HttpMethod.DELETE, "/api/v1/admin/users/5")) println("Response: ${r5.statusCode} ${r5.body}") println()
println("=== DELETE /api/v1/admin/users/5 (with auth) ===") val r6 = app.handle(HttpRequest( HttpMethod.DELETE, "/api/v1/admin/users/5", headers = mapOf("Authorization" to "Bearer secret-token") )) println("Response: ${r6.statusCode} ${r6.body}") println()
println("=== GET /nonexistent ===") val r7 = app.handle(HttpRequest(HttpMethod.GET, "/nonexistent")) println("Response: ${r7.statusCode} ${r7.body}")}The unauthenticated DELETE returns 401 because authMiddleware short-circuits;
the same request with the Authorization: Bearer secret-token header reaches the
handler and returns 204.
Run it
Section titled “Run it”-
Run the demo
main, which builds the router and fires the simulated requests:Terminal window ./gradlew run -
Run the test suite — it covers route matching, 404s, nested prefixes, path params, middleware ordering, short-circuiting, and trailing-slash normalization:
Terminal window ./gradlew test
The tests are worth reading as a spec. For example, middleware is applied in order asserts the chain runs mw1-before, mw2-before, handler, mw2-after,
mw1-after — confirming the foldRight produces correct onion-style nesting.