
上一篇我們做了 Router,能處理精確匹配、405、trailing slash。但精確匹配只能做到 /users、/health 這種固定路徑,真實的 REST API 需要 /users/123、/posts/abc-def 這種動態路徑,這篇要讓 Router 支援路徑樣板,/users/{id} 匹配 /users/123,把 id=123 提取出來給 handler 用
這篇分兩個階段。第一階段只處理 Router 這一層,路徑樣板怎麼跟 request path 比對、參數怎麼提取,測試也只斷言 match() 的回傳值,等這層跑通了,第二階段再組合到 RelixCall,讓 handler 真的能拿到參數
先寫第一階段的測試
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
class PathParamTest {
@Test
fun `single path parameter is extracted`() {
val router = Router()
router.add(Route("GET", "/users/{id}") { ok("user") })
val result = router.match("GET", "/users/123")
assertIs<MatchResult.Matched>(result)
assertEquals(mapOf("id" to "123"), result.pathParams)
}
@Test
fun `multiple path parameters are extracted`() {
val router = Router()
router.add(Route("GET", "/users/{userId}/posts/{postId}") { ok("post") })
val result = router.match("GET", "/users/u1/posts/p9")
assertIs<MatchResult.Matched>(result)
assertEquals("u1", result.pathParams["userId"])
assertEquals("p9", result.pathParams["postId"])
}
@Test
fun `static route takes priority over parameterized route`() {
val router = Router()
val staticHandler: RelixHandler = { ok("static") }
val paramHandler: RelixHandler = { ok("param") }
router.add(Route("GET", "/users/{id}", paramHandler))
router.add(Route("GET", "/users/me", staticHandler))
val result = router.match("GET", "/users/me")
assertIs<MatchResult.Matched>(result)
assertEquals(staticHandler, result.route.handler)
}
@Test
fun `segment count mismatch returns NotFound`() {
val router = Router()
router.add(Route("GET", "/users/{id}") { ok("user") })
// template 有 2 段,request 有 3 段
val result = router.match("GET", "/users/123/posts")
assertIs<MatchResult.NotFound>(result)
}
@Test
fun `trailing slash without id does not match parameter`() {
val router = Router()
router.add(Route("GET", "/users/{id}") { ok("user") })
// normalizePath 會先把結尾的 / 砍掉,所以只剩一段 ["users"],段數對不上
val result = router.match("GET", "/users/")
assertIs<MatchResult.NotFound>(result)
}
@Test
fun `router keeps the decoded path parameter`() {
val router = Router()
router.add(Route("GET", "/search/{query}") { ok("search") })
// Adapter 已從 URI.path 取得解碼後的路徑
val result = router.match("GET", "/search/hello world")
assertIs<MatchResult.Matched>(result)
assertEquals("hello world", result.pathParams["query"])
}
@Test
fun `parameterized route still supports 405`() {
val router = Router()
router.add(Route("GET", "/users/{id}") { ok("user") })
val result = router.match("POST", "/users/123")
assertIs<MatchResult.MethodNotAllowed>(result)
}
}
這七個測試現在連編譯都過不了,因為 MatchResult.Matched 還沒有 pathParams 這個欄位。接下來就是要讓它們通過測試,先補型別,再寫比對邏輯,最後接進 Router.match()
sealed class MatchResult {
data class Matched(
val route: Route,
val pathParams: Map<String, String> = emptyMap(),
) : MatchResult()
data object NotFound : MatchResult()
data class MethodNotAllowed(val allowedMethods: Set<String>) : MatchResult()
}
pathParams 預設是空 map,這樣精確匹配的路由不用特別傳參數,保持向後相容
路徑樣板的匹配有兩種常見做法,正則表達式和分段匹配,正則的好處是彈性高 (可以做 /users/{id:\\d+} 這種帶 pattern 的參數),但對系列文來說太 magic,分段匹配更直覺,一看就知道在幹嘛
概念很簡單,把 template 和 request path 都用 / 切成 segments,逐段比對
template: /users/{id} → ["users", "{id}"]
path: /users/123 → ["users", "123"]
比對過程
"users" == "users" → 靜態段,完全匹配 ✓
"{id}" vs "123" → 參數段,提取 id=123 ✓
先把 segment 切割和判斷邏輯抽成工具函式,這幾個都是 Router 的 private 成員,只是為了方便解釋才單獨列出來,下面整合時會一起放進 class 裡
private fun splitSegments(path: String): List<String> {
if (path == "/") {
return emptyList()
}
return path.trimEnd('/').split("/").drop(1) // drop(1) 去掉開頭的空字串
}
private fun isParam(segment: String): Boolean =
segment.startsWith("{") && segment.endsWith("}")
private fun paramName(segment: String): String =
segment.substring(1, segment.length - 1)
splitSegments("/users/{id}") 會回傳 ["users", "{id}"],drop(1) 是因為 /users/{id}.split("/") 會得到 ["", "users", "{id}"],第一個空字串要丟掉
然後是比對函式,一樣是 Router 的 private 成員,回傳匹配結果和提取的參數
private fun matchPath(
templateSegments: List<String>,
pathSegments: List<String>,
): Map<String, String>? {
if (templateSegments.size != pathSegments.size) return null
val params = mutableMapOf<String, String>()
for (i in templateSegments.indices) {
val template = templateSegments[i]
val actual = pathSegments[i]
when {
isParam(template) -> {
if (actual.isEmpty()) return null // 空段不匹配參數
// RelixRequest.path 來自 URI.path,這裡已經是解碼後的值
params[paramName(template)] = actual
}
template == actual -> { /* 靜態段匹配 */ }
else -> return null // 靜態段不匹配
}
}
return params
}
回傳 null 代表不匹配,回傳 Map 代表匹配成功 (map 可能是空的,代表純靜態路由),actual 已經來自解碼後的 RelixRequest.path,這一層不再重複處理
把 matchPath 整合進 Router。靜態路由優先的策略也在這裡實作
class Router {
private val routes = mutableListOf<Route>()
fun add(route: Route) {
routes += route
}
fun match(method: String, path: String): MatchResult {
val pathSegments = splitSegments(normalizePath(path))
// 收集所有 path 匹配的 route(不管 method)
data class PathMatch(
val route: Route,
val params: Map<String, String>,
val isStatic: Boolean,
)
val pathMatches = routes.mapNotNull { route ->
val templateSegments = splitSegments(route.path)
val params = matchPath(templateSegments, pathSegments)
if (params != null) {
PathMatch(route, params, isStatic = params.isEmpty()
&& templateSegments == pathSegments)
} else null
}
if (pathMatches.isEmpty()) {
return MatchResult.NotFound
}
// 靜態優先:先找完全靜態匹配 + method 正確的
val staticMatch = pathMatches
.filter { it.isStatic && it.route.method == method }
.firstOrNull()
if (staticMatch != null) {
return MatchResult.Matched(staticMatch.route, staticMatch.params)
}
// 再找參數匹配 + method 正確的
val paramMatch = pathMatches
.filter { it.route.method == method }
.firstOrNull()
if (paramMatch != null) {
return MatchResult.Matched(paramMatch.route, paramMatch.params)
}
// HEAD fallback
if (method == "HEAD") {
val getMatch = pathMatches.firstOrNull { it.route.method == "GET" }
if (getMatch != null) {
return MatchResult.Matched(getMatch.route, getMatch.params)
}
}
val allowedMethods = pathMatches.map { it.route.method }.toMutableSet().apply {
if ("GET" in this) add("HEAD")
}
return MatchResult.MethodNotAllowed(allowedMethods)
}
private fun normalizePath(path: String): String {
if (path == "/") {
return "/"
}
return path.trimEnd('/')
}
// 前面那四個 private 函式
// splitSegments、isParam、paramName、matchPath 原封不動放在這裡
}
這裡不能再用 URLDecoder,它的規則是給 query string 與 form data 使用,會把 + 當成空白,但 + 在 path 中是一般字元,第 04 篇已經用 URI.path 取得解碼後的路徑,Router 直接保存 segment 即可,否則 %25 這類內容可能被重複解碼
isStatic 的判斷有點取巧,如果 params 是空的而且 template segments 跟 path segments 完全相同,就是純靜態匹配,這樣不需要額外的旗標就能區分靜態和參數路由
第一階段到這裡結束,跑一次測試,前面那些失敗的測試應該全部通過,Router 已經會匹配路徑樣板、提取參數,也維持了原本的 405 與靜態優先行為
Router 現在把參數放在 MatchResult.Matched.pathParams 裡,但 handler 還碰不到它,第二階段要把這段路接起來
第一階段那組測試驗的是 Router 這一層,這次要驗的是從 request 到 response 的完整流程,屬於 Application 層,所以另開一個測試類別,命名沿用第 08 篇 RelixApplicationRoutingTest 的風格
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class RelixApplicationPathParamTest {
@Test
fun `GET users id returns user id in response`() {
val app = RelixApplication()
app.get("/users/{id}") { ok("User: ${pathParam("id")}") }
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("GET", "/users/42")
assertEquals(200, response.statusCode)
assertEquals("User: 42", response.bodyAsText())
}
@Test
fun `nested path params work end to end`() {
val app = RelixApplication()
app.get("/users/{userId}/posts/{postId}") {
ok("${pathParam("userId")}/${pathParam("postId")}")
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("GET", "/users/alice/posts/first")
assertEquals(200, response.statusCode)
assertEquals("alice/first", response.bodyAsText())
}
@Test
fun `pathParamOrNull returns null for an unknown name`() {
val app = RelixApplication()
app.get("/users/{id}") { ok(pathParamOrNull("name") ?: "none") }
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("GET", "/users/42")
assertEquals("none", response.bodyAsText())
}
@Test
fun `pathParam throws for an unknown name`() {
val app = RelixApplication()
app.get("/users/{id}") { ok(pathParam("name")) }
val testKit = RelixTestKit(app)
assertFailsWith<IllegalStateException> {
testKit.handleRequest("GET", "/users/42")
}
}
}
回到第 04 篇的分層原則,path parameters 是 Router 匹配後「框架產出的資料」,不是 HTTP 原始資訊,所以放在 RelixCall 而不是 RelixRequest,改的是第 06 篇建立的 RelixCall.kt
class RelixCall(
val application: RelixApplication,
val request: RelixRequest,
internal var pathParams: Map<String, String> = emptyMap(),
) {
fun pathParam(name: String): String =
pathParams[name] ?: error("Missing path param: $name")
fun pathParamOrNull(name: String): String? =
pathParams[name]
}
提供兩個 API,pathParam() 在參數不存在時直接拋例外 (因為如果路由匹配成功了,參數一定存在,不存在代表程式有 bug),pathParamOrNull() 回傳 nullable 給特殊情況用
接著改 RelixApplication.kt 的 handle(),不要為了 path params 建立第二個 RelixCall,否則前面放進 call 的狀態會消失,Router 應把匹配結果寫回同一個生命週期物件
fun handle(call: RelixCall): RelixResponse {
val result = router.match(call.request.method, call.request.path)
val response = when (result) {
is MatchResult.Matched -> {
call.pathParams = result.pathParams
result.route.handler(call)
}
is MatchResult.NotFound -> notFound()
is MatchResult.MethodNotAllowed ->
methodNotAllowed(result.allowedMethods)
}
return if (call.request.method == "HEAD") {
response.copy(body = ByteArray(0))
} else {
response
}
}
再跑一次測試,第二階段那四個也通過了,它們走過 Router 匹配、pathParams 提取、RelixCall 的參數寫入、handler 內的 pathParam() 存取,任何一環出問題都會立刻被抓到
URL decode 的時機
URL decode 已在 adapter 讀取 URI.path 時完成,Router 與 pathParam() 都不再 decode,避免 %25 被處理兩次,也不會把 path 裡的 + 誤當空白
重複的 param 名稱
/{id}/{id} 這種路徑樣板會讓後面的值覆蓋前面的 (因為放進 Map 時 key 相同),一般的框架通常會在註冊路由時就檢查並拋例外,這裡我們先不做這個檢查,但如果你覺得有必要,在 add() 方法裡加一行 require 就好
靜態優先的實作方式
目前用 params.isEmpty() && template == path 判斷靜態路由,足以處理固定 segment 與必填參數,若未來加入可選參數或 wildcard,應讓 Route 在註冊時解析成明確的 segment 型別,而不是繼續靠空 Map 推論
路徑參數讓 Router 從只能匹配固定路徑,進一步支援 REST 風格的動態路徑,分段匹配的邏輯不複雜,但仍要明確決定解碼時機、靜態優先策略與空段處理,這些行為都用測試固定下來,後續重構 Router 時才有可靠的回歸保護
下一篇我們進入路由 DSL,用 Kotlin 的 Lambda with Receiver 寫出 routing { get("/hello") { ok("Hello!") } } 這種語法。這篇會深入講 Lambda with Receiver 的原理,也就是 RelixCall.() -> RelixResponse 這個型別背後真正在發生什麼事
同步刊登於 Blog
圖片來源:AI 產生