
第 29 篇把 Todo API 的 CRUD 跑起來了,距離「能上線」還有幾個必備零件
第 29 篇是一個端點一輪,這篇換成一個零件一輪,零件之間有依賴順序,驗證只碰 request body
授權要先知道「你是誰」,所以認證要排在授權前面,錯誤處理要等到有人真的開始丟例外才有事情做,CORS 則是最外層的守門員,放最後裝
程式碼動的是第 29 篇那四個檔案,Todo.kt 的 Todo 加一個欄位,TodoRepository.kt 有兩個方法要換簽章,TodoRoutes.kt 除了改寫 todoRoutes() 之外還多了 createTodoValidator 跟 requireOwnedTodo(),app 組裝一樣在 main.kt。測試全部放在新的 TodoIntegrationTest.kt
測試的 app helper 也是逐輪長大,第一輪先把第 29 篇的 todoApp { } 原封搬過來改個名字,之後每一輪只加那一輪需要的 plugin,到第五輪才會是完整的設定
第一輪要的行為是空白的 title 回 422,太長的 title 也回 422,順便把測試的骨架搭好,新增 TodoIntegrationTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class TodoIntegrationTest {
private fun fullApp(block: TestRelixApplication.() -> Unit) = relixTest {
install(ContentNegotiation) { json() }
services {
singleton<TodoRepository> { InMemoryTodoRepository() }
}
routing { todoRoutes() }
block()
}
@Test
fun `POST with empty title returns 422`() = fullApp {
val response = handleRequest(HttpMethod.Post, "/api/todos") {
bearerToken("alice-token")
setJsonBody("""{"title":""}""")
}
assertEquals(422, response.status)
val body = response.bodyAsText()
assertTrue(body.contains("title"))
}
@Test
fun `POST with too long title returns 422`() = fullApp {
val longTitle = "a".repeat(201)
val response = handleRequest(HttpMethod.Post, "/api/todos") {
bearerToken("alice-token")
setJsonBody("""{"title":"$longTitle"}""")
}
assertEquals(422, response.status)
}
}
fullApp { } 現在就是第 29 篇 todoApp { } 的複製品,只換了名字,裝 ContentNegotiation、註冊 repository、掛上 todoRoutes(),叫 fullApp 只是因為它五輪之後會裝滿一整組 plugin
兩個測試都先呼叫了 bearerToken("alice-token"),但這一輪還沒裝 Authentication,那個 header 送出去沒有人讀,第二輪裝上去之後才會真的生效,先寫在這裡,後面三輪就不用回頭補
現在跑,兩個測試都失敗,空白的 title 照樣被建出來,回的是 201 不是 422
第 22 篇做的 Validation DSL 在這裡派上用場,validator 是檔案級的 val,補進 TodoRoutes.kt 的 todoRoutes() 上面
val createTodoValidator = validate<CreateTodoRequest> {
field(CreateTodoRequest::title) {
notBlank()
maxLength(200)
}
}
post { } 加驗證,改的是同一個檔案裡的 handler
post {
val req = receive<CreateTodoRequest>()
val result = createTodoValidator.validate(req)
if (!result.isValid) {
return@post unprocessableEntity(result)
}
val repo = resolve<TodoRepository>()
val todo = repo.create(req.title)
created(todo)
}
unprocessableEntity() 回 422 加上結構化的錯誤 JSON,第 22 篇定義過這個格式,body 裡面會列出是哪個欄位、違反了哪條規則,所以測試才能斷言 body.contains("title")
這一輪的錯誤是 handler 自己組好 response 回出去的,沒有例外在 pipeline 裡,所以還不需要有人在外面接,第四輪換成 throw 的時候情況就不一樣了
第二輪兩個測試,一個是完全沒帶 token,一個是帶了無效的 token,兩個都要回 401,補進 TodoIntegrationTest.kt
@Test
fun `GET without token returns 401`() = fullApp {
val response = handleRequest(HttpMethod.Get, "/api/todos")
assertEquals(401, response.status)
assertTrue(response.header("WWW-Authenticate")?.contains("Bearer") == true)
}
@Test
fun `GET with invalid token returns 401`() = fullApp {
val response = handleRequest(HttpMethod.Get, "/api/todos") {
bearerToken("bad-token")
}
assertEquals(401, response.status)
}
第一個測試多斷言了 WWW-Authenticate header,理由跟第 23 篇一樣,401 只回狀態碼不夠,client 還需要知道要用哪種方式認證
現在兩個測試都拿到 200,因為 /api/todos 根本沒被保護起來
authenticate { }第 23 篇的 Authentication plugin 直接拿來用,UserPrincipal 那時候也已經定義在 Auth.kt 了,這裡不用重新定義
helper 補一段 plugin 安裝,改的是 TodoIntegrationTest.kt 的 fullApp { }
install(Authentication) {
bearer { token ->
when (token) {
"alice-token" -> UserPrincipal("1", "Alice")
"bob-token" -> UserPrincipal("2", "Bob")
else -> null
}
}
}
routing 那行也要改,原本是 routing { todoRoutes() }
routing {
authenticate {
todoRoutes()
}
}
要注意,這裡的 token 驗證是寫死的 lookup table,真實應用會查資料庫或驗 JWT,兩個使用者是後面要用的,Alice 跟 Bob 各自有自己的 userId
authenticate { } 裡面的路由全部受保護,沒帶 token 回 401,token 對不到任何一個使用者也回 401,兩種都會帶上 WWW-Authenticate: Bearer,todoRoutes() 本身一個字都沒動,保護是包在外面加的,這也是第 29 篇把 route 抽成 RoutingBuilder extension 的好處,要不要保護是呼叫端的決定
TodoRoutes.kt 這一輪不用動,main.kt 那邊的對應改法留到最後一節
認證解決「你是誰」,授權解決「你能做什麼」,授權的第一件事是資料隔離,Bob 建立的 Todo 不該出現在 Alice 的清單裡,測試補進 TodoIntegrationTest.kt
@Test
fun `alice cannot see bob's todos`() = fullApp {
// Bob 建立一個 todo
handleRequest(HttpMethod.Post, "/api/todos") {
bearerToken("bob-token")
setJsonBody("""{"title":"Bob's task"}""")
}
// Alice 列出自己的 todos
val listRes = handleRequest(HttpMethod.Get, "/api/todos") {
bearerToken("alice-token")
}
assertEquals(200, listRes.status)
assertEquals(0, listRes.jsonArray().size) // Alice 看不到 Bob 的
}
兩個 handleRequest() 在同一個 fullApp { } 裡面,共用同一份 repository,所以 Bob 剛剛建立的那筆確實還在 store 裡面,Alice 拿到 0 筆才有意義
現在 Alice 會拿到 1 筆,list() 把 store 裡的東西全吐出來,根本不知道誰是誰
Todo 要記得自己屬於誰,改的是 Todo.kt
@Serializable
data class Todo(
val id: String,
val title: String,
val completed: Boolean,
val createdAt: String,
val ownerId: String,
)
介面有兩個方法要換簽章,改 TodoRepository.kt
interface TodoRepository {
fun list(ownerId: String): List<Todo>
fun get(id: String): Todo?
fun create(title: String, ownerId: String): Todo
fun update(id: String, req: UpdateTodoRequest): Todo?
fun delete(id: String): Boolean
}
get() 沒有跟著加 ownerId 參數,如果 get(id, ownerId) 找不到就回 null,handler 就分不出「這筆不存在」跟「這筆存在但不是你的」,前者要回 404,後者要回 403,兩件事的意思差很多,所以 get() 維持原簽章,把判斷留給 handler
實作補在同一個檔案,InMemoryTodoRepository 的 list() 跟 create() 各改一次
override fun list(ownerId: String): List<Todo> {
return store.values.filter { it.ownerId == ownerId }
}
override fun create(title: String, ownerId: String): Todo {
val id = idCounter.incrementAndGet().toString()
val todo = Todo(
id = id,
title = title,
completed = false,
createdAt = Instant.now().toString(),
ownerId = ownerId,
)
store[id] = todo
return todo
}
handler 那邊要拿到「現在是誰在呼叫」,改 TodoRoutes.kt 的 get { } 跟 post { }
get {
val repo = resolve<TodoRepository>()
val user = principal<UserPrincipal>()
ok(repo.list(user.userId))
}
post {
val req = receive<CreateTodoRequest>()
val result = createTodoValidator.validate(req)
if (!result.isValid) {
return@post unprocessableEntity(result)
}
val repo = resolve<TodoRepository>()
val user = principal<UserPrincipal>()
val todo = repo.create(req.title, user.userId)
created(todo)
}
principal<UserPrincipal>() 是第 23 篇做的取值函式,auth middleware 驗過 token 之後把 principal 放進 CallContext,handler 從那裡拿出來,userId 直接當 ownerId 用
helper 這一輪不用動,第二輪的 authenticate { } 已經保證進得來的 request 都有 principal
簽章一改,第 29 篇 TodoApiTest.kt 那十個測試就跑不過了,它們編譯其實沒問題,因為打的是 HTTP,碰不到 repository 的簽章,壞的是 helper 沒裝 Authentication、routing 也沒包 authenticate { },handler 一走到 principal<UserPrincipal>() 就丟 401,而那份 helper 連 StatusPages 都沒有,例外會直接穿出去,整批報錯,舊的那份可以直接刪掉,它涵蓋的行為在最後一輪會用帶認證的版本再走一次
看不到不等於動不了,Bob 建立的 Todo 有一個 id,Alice 只要知道那個 id,GET、PUT、DELETE /api/todos/{id} 三條路都走得進去,這一輪要把三條一起堵起來
三個測試都要先讓 Bob 建一筆再拿 id,那段重複三次沒意思,先抽一個測試專用的 helper,補進 TodoIntegrationTest.kt
private fun TestRelixApplication.bobCreatesTodo(): String {
val res = handleRequest(HttpMethod.Post, "/api/todos") {
bearerToken("bob-token")
setJsonBody("""{"title":"Bob's task"}""")
}
return res.json()["id"] as String
}
它是 TestRelixApplication 的 extension,跟測試 body 拿到的是同一個 receiver,所以裡面照樣呼叫得到 handleRequest(),回傳的 id 就是 Bob 那筆的 id
三個測試也補進同一個檔案
@Test
fun `alice cannot get bob's todo`() = fullApp {
val todoId = bobCreatesTodo()
val response = handleRequest(HttpMethod.Get, "/api/todos/$todoId") {
bearerToken("alice-token")
}
assertEquals(403, response.status)
}
@Test
fun `alice cannot update bob's todo`() = fullApp {
val todoId = bobCreatesTodo()
val response = handleRequest(HttpMethod.Put, "/api/todos/$todoId") {
bearerToken("alice-token")
setJsonBody("""{"completed":true}""")
}
assertEquals(403, response.status)
}
@Test
fun `alice cannot delete bob's todo`() = fullApp {
val todoId = bobCreatesTodo()
val response = handleRequest(HttpMethod.Delete, "/api/todos/$todoId") {
bearerToken("alice-token")
}
assertEquals(403, response.status)
}
403 Forbidden,你已認證,但這不是你的 Todo,跟第三輪的 404 是兩件事,Bob 那筆確實存在,只是不屬於 Alice
目前的程式碼,Alice 讀得到 Bob 的內容 (200)、改得動 Bob 的狀態 (200)、也刪得掉 Bob 的資料 (204)
最直接的寫法是在 handler 裡面自己查、自己比,先只改一個,TodoRoutes.kt 的 delete("/{id}")
delete("/{id}") {
val user = principal<UserPrincipal>()
val repo = resolve<TodoRepository>()
val todo = repo.get(pathParam("id"))
?: return@delete notFound("Todo not found")
if (todo.ownerId != user.userId) {
return@delete RelixResponse(403, mapOf(), "Forbidden".toByteArray())
}
repo.delete(todo.id)
noContent()
}
repo.delete() 的回傳值不用再判斷了,前面已經確認過那筆東西存在
跑一次,delete 那個測試綠了,get 跟 put 兩個還是紅的
要讓另外兩個也綠,最省事的做法是把那五行貼過去兩次,put("/{id}") 會變成這樣
put("/{id}") {
val user = principal<UserPrincipal>()
val repo = resolve<TodoRepository>()
val todo = repo.get(pathParam("id"))
?: return@put notFound("Todo not found")
if (todo.ownerId != user.userId) {
return@put RelixResponse(403, mapOf(), "Forbidden".toByteArray())
}
val req = receive<UpdateTodoRequest>()
val updated = repo.update(pathParam("id"), req)!!
ok(updated)
}
get("/{id}") 再貼一次,三個 handler 重複同一段是個壞味道,重複一次能忍,三次以上會開始 drift (其中一個 handler 忘了檢查就是漏洞),實務上有兩種解法
// 方案 A:抽 helper extension,每個 handler 多一行
// NotFoundException / ForbiddenException 都是第 16 篇定義的,兩個都要帶 message
suspend fun RelixCall.requireOwnedTodo(): Todo {
val user = principal<UserPrincipal>()
val todo = resolve<TodoRepository>().get(pathParam("id"))
?: throw NotFoundException("Todo not found")
if (todo.ownerId != user.userId) {
throw ForbiddenException("Not your todo")
}
return todo
}
// handler 變成
put("/{id}") {
val todo = requireOwnedTodo()
val req = receive<UpdateTodoRequest>()
ok(resolve<TodoRepository>().update(todo.id, req)!!)
}
// 方案 B:寫一個 OwnershipMiddleware,掛在 /api/todos/{id} 的子路由上
route("/api/todos/{id}") {
use(ownershipCheck<Todo>(repo = { resolve() }, ownerOf = { it.ownerId }))
get { ok(resolve<TodoRepository>().get(pathParam("id"))!!) }
put { /* ... */ }
delete { /* ... */ }
}
A 比較直觀、跟現在的程式碼最像,B 把授權從 handler 完全抽走,handler 只剩商業邏輯,這篇走 A,requireOwnedTodo() 一行就看得懂它做了什麼,B 的 ownershipCheck<T>() 還要再解釋一層泛型 middleware,不過你自己的專案裡也可以考慮用 B
requireOwnedTodo() 補進 TodoRoutes.kt,就是方案 A 那段,放在 todoRoutes() 後面,三個帶 id 的 handler 全部改成走它
get("/{id}") {
val todo = requireOwnedTodo()
ok(todo)
}
put("/{id}") {
val req = receive<UpdateTodoRequest>()
val repo = resolve<TodoRepository>()
val todo = requireOwnedTodo()
val updatedTodo = repo.update(todo.id, req)
ok(updatedTodo!!)
}
delete("/{id}") {
val repo = resolve<TodoRepository>()
val todo = requireOwnedTodo()
repo.delete(todo.id)
noContent()
}
第 29 篇的「常見陷阱」講過 repository 回 null 而不是丟 exception,這裡沒有推翻它,get() 照樣回 Todo?,改成丟例外的是 handler 這一層的 requireOwnedTodo(),它一次要表達兩種失敗、而且回傳型別是 Todo 不是 Todo?,用 return@get 表達不了,才換成例外
重構完再跑一次,三個測試不是綠的,是三個 500
這就是 StatusPages 出場的原因,前三輪的錯誤都是 handler 直接回 response,422 是 unprocessableEntity() 產生的,401 是 auth middleware 自己擋掉的,delete 剛剛那版內嵌檢查也是自己 return@delete 回去的,沒有例外飛出來,也就不需要有人接,換成 requireOwnedTodo() 之後,NotFoundException 跟 ForbiddenException 第一次離開了 handler,沒人接的話它會一路穿出去變成 500
所以 StatusPages 不是這一輪順手裝的,是重構把測試從 403 打成 500,得把它們接回 403,剛才那三個測試就是它的測試
helper 補上第三個 plugin,改 TodoIntegrationTest.kt 的 fullApp { },排在 Authentication 前面
install(StatusPages) {
exception<NotFoundException> { cause ->
errorResponse(404, cause.message ?: "Not found")
}
// ForbiddenException 不用另外註冊:它是 RelixHttpException,
// StatusPages 找不到映射時會直接用它自己帶的 403
}
安裝順序就是 pipeline 由外到內,StatusPages 排在 Authentication 外面,auth middleware 跟 handler 兩邊丟出來的例外都在它的守備範圍內,三個測試回到 403,訊息是 Not your todo
順帶一提,exception<NotFoundException> 還沒有測試到,它要到最後一輪的結構化 404 才會被踩到
前端用 Vite 預設跑在 5173 port,API 跑在 8080,瀏覽器眼中這是兩個不同的來源,跨域的非簡單請求送出去之前,瀏覽器會先送一個 OPTIONS 的 preflight 問「我可以這樣打嗎」,兩個測試一個測 preflight,一個測正常回應有沒有帶上 CORS header
@Test
fun `CORS preflight returns 204`() = fullApp {
val response = handleRequest(HttpMethod.Options, "/api/todos") {
header("Origin", "http://localhost:5173")
header("Access-Control-Request-Method", "POST")
header("Access-Control-Request-Headers", "Content-Type, Authorization")
}
assertEquals(204, response.status)
assertEquals("http://localhost:5173", response.header("Access-Control-Allow-Origin"))
}
@Test
fun `CORS headers present on normal response`() = fullApp {
val response = handleRequest(HttpMethod.Get, "/api/todos") {
bearerToken("alice-token")
header("Origin", "http://localhost:5173")
}
assertEquals(200, response.status)
assertEquals("http://localhost:5173", response.header("Access-Control-Allow-Origin"))
}
兩個測試一樣補進 TodoIntegrationTest.kt,preflight 那個沒有 bearerToken(),這不是漏寫,瀏覽器送 preflight 的時候本來就不會帶 Authorization,測試要模擬的就是真實的行為
現在第一個測試回 405,第二個測試回 200 但是沒有 Access-Control-Allow-Origin
第一個為什麼是 405 不是 401 ? 因為 todoRoutes() 只註冊了 GET 跟 POST,router 對 OPTIONS /api/todos 回的是 MethodNotAllowed,那個分支不會設 call.matchedRoute (第 13 篇),而第 23 篇 checkAuth() 的第一行就是 val route = matchedRoute ?: return null,沒對到路由就直接放行,request 掉到 405 的 terminal handler
第 17 篇的 CORS middleware 在第 18 篇已經包成 plugin 了,設定照抄第 17 篇的白名單模式,helper 補上最後一個 plugin,位置很重要
install(ContentNegotiation) { json() }
// 安裝順序 = pipeline 由外到內,CORS 要在 Authentication 前面,
// 否則 auth 直接回的 401 不會帶 CORS header
install(Cors) {
allowedOrigins = listOf("http://localhost:5173")
allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS")
allowedHeaders = listOf("Content-Type", "Authorization")
}
helper 到這裡裝了四個 plugin,但只有三個會進 pipeline,ContentNegotiation 的 install() 只做一件事,把 converter registry 掛到 application 上 (第 20 篇),它沒有 use() 任何 middleware,所以它排第幾都不影響 pipeline,只要在 request 進來之前裝好就行,下面那張圖會把它畫在洋蔥外面
Cors 攔到 OPTIONS,直接短路回 204,request 根本不會走到 auth 那一層,第一個測試就從 405 變成 204,第二個測試則是 CORS middleware 的另一半工作,正常的 request 放行過去,回程再補上 Access-Control-Allow-Origin
順序真正要命的地方不在 preflight,剛才算過,preflight 因為 matchedRoute 是 null 本來就穿得過 auth,所以就算把 Cors 挪到 Authentication 後面,這兩個測試照樣會過,會出事的是錯誤回應,auth 排在外層的時候,一個沒帶 token 的 GET 由 auth 當場回 401,那條 request 根本走不到 Cors,回去的 401 身上不會有 Access-Control-Allow-Origin,瀏覽器看到的不是 401,是一個什麼都讀不到的 CORS 錯誤,前端連「我沒登入」都不知道
把層次的名字講清楚,這條規則就好記了。真正排出層次的是 Cors、StatusPages、Authentication 三個,每一個的 install() 裡面都有一行 application.use(...),use() 的呼叫順序就是洋蔥的層數,先裝的在外層 (outer),後裝的在內層 (inner),request 由外往內走是去程,response 由內往外回是回程
ContentNegotiation ← 不在洋蔥上,handler 呼叫 receive() / ok() 的時候才用得到
Cors ← 去程攔 preflight 短路回 204,回程補 Access-Control-Allow-Origin
StatusPages ← 去程只是 try,回程接住往上飛的例外換成 JSON
Authentication ← 去程驗 token,不過就當場回 401
Router
handler ← 商業邏輯
每一層在去程跟回程各做各的事,看清楚這張圖,順序就只剩一條規則,誰要加工誰的輸出,誰就要在外面
Cors 要在 Authentication 外面,auth 的 401 是「當場回」的,那個 response 得經過 Cors 的回程才會帶上 CORS headerCors 也要在 StatusPages 外面,理由一模一樣,requireOwnedTodo() 丟的例外會穿過 Cors 的 next() 一路往上飛 (Cors 那行 val response = next() 根本沒機會執行),被 StatusPages 接住換成 403 JSON,StatusPages 要是排在 Cors 外面,這個 403 就再也回不到 Cors 手上第二條實測過,只把 install(StatusPages) 挪到 install(Cors) 前面,其他都不動,帶著 Origin 打一次 Alice 刪 Bob 的 todo
StatusPages 的位置 |
status | Access-Control-Allow-Origin |
|---|---|---|
在 Cors 外面 |
403 | 沒有 |
在 Cors 裡面 |
403 | http://localhost:5173 |
兩種排法十二個測試都是綠的,因為沒有一個測試在檢查錯誤回應上的 CORS header,要測到它得補「401 也要帶 CORS header」跟「403 也要帶 CORS header」這種測試
最後一輪不加任何實作,兩個測試各自確認一件事,一個是五個端點帶著認證跑完一整輪,一個是錯誤回應的格式,兩個都是 TodoIntegrationTest.kt 的最後兩個測試
@Test
fun `full CRUD flow with auth`() = fullApp {
// Alice 建立
val createRes = handleRequest(HttpMethod.Post, "/api/todos") {
bearerToken("alice-token")
setJsonBody("""{"title":"Buy milk"}""")
}
assertEquals(201, createRes.status)
val id = createRes.json()["id"] as String
// Alice 取回
val getRes = handleRequest(HttpMethod.Get, "/api/todos/$id") {
bearerToken("alice-token")
}
assertEquals(200, getRes.status)
assertEquals("Buy milk", getRes.json()["title"])
// Alice 更新
val updateRes = handleRequest(HttpMethod.Put, "/api/todos/$id") {
bearerToken("alice-token")
setJsonBody("""{"completed":true}""")
}
assertEquals(200, updateRes.status)
assertEquals(true, updateRes.json()["completed"])
// Alice 刪除
val deleteRes = handleRequest(HttpMethod.Delete, "/api/todos/$id") {
bearerToken("alice-token")
}
assertEquals(204, deleteRes.status)
// Alice 確認刪除了
val afterDelete = handleRequest(HttpMethod.Get, "/api/todos/$id") {
bearerToken("alice-token")
}
assertEquals(404, afterDelete.status)
}
@Test
fun `GET nonexistent todo returns structured 404`() = fullApp {
val response = handleRequest(HttpMethod.Get, "/api/todos/999") {
bearerToken("alice-token")
}
assertEquals(404, response.status)
val json = response.json()
assertEquals(404, json["status"])
assertTrue((json["message"] as String).isNotEmpty())
}
第二個測試斷言的不只是狀態碼,還有 body 的形狀,404 的 JSON 裡面有 status 跟 message 兩個欄位,這是第 27 篇 errorResponse() 的格式,走的是第四輪裝好的 StatusPages,client 不用猜 response body 長什麼樣,不管是 404、422 還是 500,結構都一致
{ "status": 404, "message": "Todo not found" }
全部的測試到這裡全部通過
fullApp { } 五輪下來長成這樣,測試 body 只關心業務行為,不碰任何基礎設施的細節
private fun fullApp(block: TestRelixApplication.() -> Unit) = relixTest {
install(ContentNegotiation) { json() }
// 安裝順序 = pipeline 由外到內,CORS 要在 Authentication 前面,
// 否則 auth 直接回的 401 不會帶 CORS header
install(Cors) {
allowedOrigins = listOf("http://localhost:5173")
allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS")
allowedHeaders = listOf("Content-Type", "Authorization")
}
install(StatusPages) {
exception<NotFoundException> { cause ->
errorResponse(404, cause.message ?: "Not found")
}
// ForbiddenException 不用另外註冊:它是 RelixHttpException,
// StatusPages 找不到映射時會直接用它自己帶的 403
}
install(Authentication) {
bearer { token ->
when (token) {
"alice-token" -> UserPrincipal("1", "Alice")
"bob-token" -> UserPrincipal("2", "Bob")
else -> null
}
}
}
services {
singleton<TodoRepository> { InMemoryTodoRepository() }
}
routing {
authenticate {
todoRoutes()
}
}
block()
}
每一個測試都走完整 pipeline (CORS → StatusPages → Auth → Router → Handler),跟正式跑起來的那份走的是同一條
五輪各改一小塊,TodoRoutes.kt 的 todoRoutes() 現在長這樣
fun RoutingBuilder.todoRoutes() {
route("/api") {
route("/todos") {
get {
val repo = resolve<TodoRepository>()
val user = principal<UserPrincipal>()
ok(repo.list(user.userId))
}
post {
val req = receive<CreateTodoRequest>()
val result = createTodoValidator.validate(req)
if (!result.isValid) {
return@post unprocessableEntity(result)
}
val repo = resolve<TodoRepository>()
val user = principal<UserPrincipal>()
val todo = repo.create(req.title, user.userId)
created(todo)
}
get("/{id}") {
val todo = requireOwnedTodo()
ok(todo)
}
put("/{id}") {
val req = receive<UpdateTodoRequest>()
val repo = resolve<TodoRepository>()
val todo = requireOwnedTodo()
val updatedTodo = repo.update(todo.id, req)
ok(updatedTodo!!)
}
delete("/{id}") {
val repo = resolve<TodoRepository>()
val todo = requireOwnedTodo()
repo.delete(todo.id)
noContent()
}
}
}
}
跟第 29 篇的版本比,改動有四處
get { } 改成先拿 principal,再用 list(user.userId) 只列出自己的post { } 多一段 validation,不合法就回 422get("/{id}")、put、delete 都改成走 requireOwnedTodo(),一次處理「不存在 → 404」和「不是你的 → 403」delete 不用再自己判斷 Boolean,因為 requireOwnedTodo() 已經確認過東西存在createTodoValidator 跟 requireOwnedTodo() 也在這個檔案裡,一個在 todoRoutes() 前面,一個在後面
main.kt 那份 main() 在第 29 篇只有四個步驟,這裡中間多插三個 plugin
fun main() {
val app = Relix {
development = true
}
app.install(ContentNegotiation) { json() }
app.install(Cors) {
allowedOrigins = listOf("http://localhost:5173")
allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS")
allowedHeaders = listOf("Content-Type", "Authorization")
}
app.install(StatusPages) {
exception<NotFoundException> { cause ->
errorResponse(404, cause.message ?: "Not found")
}
exception<ValidationException> { cause ->
errorResponse(422, cause.message ?: "Validation failed")
}
}
app.install(Authentication) {
bearer { token ->
// 正式環境換成查資料庫或驗 JWT
when (token) {
"alice-token" -> UserPrincipal("1", "Alice")
"bob-token" -> UserPrincipal("2", "Bob")
else -> null
}
}
}
app.services {
singleton<TodoRepository> { InMemoryTodoRepository() }
}
app.routing {
get("/health") { ok("ok") }
authenticate {
todoRoutes()
}
}
app.start()
}
Plugin 安裝順序
跟測試的 fullApp { } 有兩處不一樣,第一處在這一段,多註冊了 ValidationException 的映射,測試裡的驗證失敗走的是 unprocessableEntity() 這條路,正式環境可能有別的地方會直接 throw
bearer { } 這裡還是那份寫死的 lookup table,跟測試一模一樣,這樣這份 main() 貼上去就跑得起來,後面那節的 curl 打的就是它,真的要上線,這幾行換成查資料庫或驗 JWT,UserPrincipal 的兩個參數照樣從查出來的使用者填
第二處是 /health,放在 authenticate { } 外面,不需要認證,監控系統打 health check 不用帶 token
第 29 篇那節是五個端點各打一次,這節換成打四個新零件,驗證、認證、授權、CORS,看它們在真的 server 上長什麼樣子
下面的輸出就是上面那份 main() 跑出來的,Alice 是 alice-token,Bob 是 bob-token
沒帶 token
curl -i localhost:8080/api/todos
HTTP/1.1 401 Unauthorized
Www-authenticate: Bearer
Content-type: text/plain; charset=utf-8
Content-length: 12
Unauthorized
Www-authenticate 的大小寫是 JDK HttpServer 正規化過的,第 23 篇講過,這條 401 是 auth middleware 自己回的 unauthorized(),沒有例外飛出來,所以 body 是 text/plain 而不是 StatusPages 的 JSON 格式
這條 curl 沒帶 Origin,所以回應上一個 CORS 相關的 header 都沒有,連 Vary: Origin 也沒有,第 17 篇的 CORS middleware 第一件事就是看 request.header("Origin"),是 null 就整個 middleware 讓路,直接 next()。等一下帶 Origin 的那兩條才看得到 Vary: Origin,那是在宣告「這個回應的內容取決於 Origin」,共享快取才不會把 A 站的回應餵給 B 站
空白的 title
curl -i -X POST localhost:8080/api/todos \
-H 'Authorization: Bearer alice-token' \
-H 'Content-Type: application/json' \
-d '{"title":""}'
HTTP/1.1 422
Content-type: application/json; charset=utf-8
Content-length: 103
{"status":422,"message":"Validation failed","errors":[{"field":"title","message":"must not be blank"}]}
errors 陣列指名了是哪個欄位、違反哪條規則,這是第 22 篇 unprocessableEntity() 的格式,狀態列只有 422 沒有 Unprocessable Entity,理由第 22 篇講過,JDK HttpServer 認不得的狀態碼就不補 reason phrase
Alice 跟 Bob 各建一筆
curl -i -X POST localhost:8080/api/todos \
-H 'Authorization: Bearer alice-token' \
-H 'Content-Type: application/json' \
-d '{"title":"Buy milk"}'
HTTP/1.1 201 Created
Content-type: application/json; charset=utf-8
Content-length: 103
{"id":"1","title":"Buy milk","completed":false,"createdAt":"2026-08-21T06:58:13.628154Z","ownerId":"1"}
curl -i -X POST localhost:8080/api/todos \
-H 'Authorization: Bearer bob-token' \
-H 'Content-Type: application/json' \
-d '{"title":"Bob task"}'
HTTP/1.1 201 Created
Content-type: application/json; charset=utf-8
Content-length: 103
{"id":"2","title":"Bob task","completed":false,"createdAt":"2026-08-21T06:58:35.806847Z","ownerId":"2"}
跟第 29 篇的 201 比多了 ownerId,值是 principal<UserPrincipal>().userId,Alice 那筆是 "1",Bob 那筆是 "2",兩筆都在同一份 InMemoryTodoRepository 裡面,id 由同一個 counter 發號,所以 Bob 拿到的是 "2"
Alice 只看得到自己的
curl -i localhost:8080/api/todos \
-H 'Authorization: Bearer alice-token'
HTTP/1.1 200 OK
Content-type: application/json; charset=utf-8
Content-length: 105
[{"id":"1","title":"Buy milk","completed":false,"createdAt":"2026-08-21T06:58:13.628154Z","ownerId":"1"}]
store 裡面有兩筆,Alice 拿到一筆,list(user.userId) 的過濾在真實 server 上也是同一套
Alice 動 Bob 的那筆
curl -i -X DELETE localhost:8080/api/todos/2 \
-H 'Authorization: Bearer alice-token'
HTTP/1.1 403 Forbidden
Content-type: application/json; charset=utf-8
Content-length: 40
{"status":403,"message":"Not your todo"}
403 而不是 404,東西存在,只是不是你的,ForbiddenException 沒有註冊在 install(StatusPages) { } 裡面,這個 403 是 StatusPages 找不到映射之後,直接用 RelixHttpException 自己帶的 status code 產生的,前面那行註解講的就是這條路
查一個真的不存在的 id 才是 404
curl -i localhost:8080/api/todos/999 \
-H 'Authorization: Bearer alice-token'
HTTP/1.1 404 Not Found
Content-type: application/json; charset=utf-8
Content-length: 41
{"status":404,"message":"Todo not found"}
CORS preflight
curl -i -X OPTIONS localhost:8080/api/todos \
-H 'Origin: http://localhost:5173' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: Content-Type, Authorization'
HTTP/1.1 204 No Content
Access-control-allow-headers: Content-Type, Authorization
Access-control-max-age: 86400
Access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS
Access-control-allow-origin: http://localhost:5173
Vary: Origin
沒帶 Authorization 也拿得到 204,Cors 攔到 preflight 就短路回去,request 根本沒走到 auth
順序的證據
前面說「誰要加工誰的輸出,誰就要在外面」,那十二個測試沒有涵蓋這件事,用 curl 打一次就看得到,一個沒帶 token、但帶了 Origin 的請求
curl -i localhost:8080/api/todos \
-H 'Origin: http://localhost:5173'
HTTP/1.1 401 Unauthorized
Www-authenticate: Bearer
Content-type: text/plain; charset=utf-8
Access-control-allow-origin: http://localhost:5173
Vary: Origin
Content-length: 12
Unauthorized
401 身上有 Access-control-allow-origin。auth 當場擋下這條 request,回程還是穿過 Cors 才出去,所以瀏覽器讀得到這個 401,把 install(Cors) 挪到 install(Authentication) 後面,這個 header 就會消失,前端看到的會變成一個什麼都讀不到的 CORS 錯誤
CORS 要放在最外層
常見的說法是「preflight 不帶 Authorization,auth 先跑會回 401」,但在 Relix 上不是這樣,OPTIONS 對不到路由,matchedRoute 是 null,checkAuth() 直接放行,preflight 拿到的是 405,真正的理由是錯誤回應也要帶 CORS header,Authentication 或 StatusPages 排到 Cors 外層的話,它們產生的 401、403、404 都走不到 Cors 的回程,瀏覽器只看得到一個什麼都讀不到的 CORS 失敗,底下真正的狀態碼它拿不到
同一個 plugin 裝兩次會炸
install() 的第一行是 check(installedPlugins.add(plugin::class)),同一個 plugin 裝第二次會丟 IllegalStateException: Plugin 'ContentNegotiation' is already installed,這個例外是在組 app 的時候丟的,不是處理 request 的時候,所以 fullApp { } 裡面多打一行 install(ContentNegotiation),整個測試類別會一起失敗,看到一整批測試同時失敗、堆疊全部指向 helper 的同一行,先數一下 plugin 有沒有裝重複,那不是順序的問題
為什麼不註冊 status(404) ?
第 27 篇的 status() 定義會改寫所有 404 response。第四輪之後,三個帶 id 的 handler 都改走 requireOwnedTodo() 丟例外,「Todo not found」這條已經由 exception<NotFoundException> 接住了,剩下的 404 只有 router 找不到路由那種。再註冊一次 status(404) 只會讓兩套規則互相覆蓋,所以這個應用只做 exception mapping
測試裡的 token 寫死可以嗎 ?
測試裡完全沒問題,測試的重點是驗證「認證和授權的行為」,不是 token 的產生方式,寫死的 lookup table 讓測試可預測、可重複,真實應用會換成 JWT 驗證或資料庫查詢,但測試 helper 可以繼續用寫死的 token
五個零件分成五次循環,一次只推一個行為
第一輪 createTodoValidator 擋住不合法的 title 回 422,第二輪把整組路由包進 authenticate { },第三輪讓 Todo 帶上 ownerId,list() 依 owner 過濾,第四輪三個測試逼出「不是你的就 403」,先用內嵌檢查讓 delete 轉綠,重構成 requireOwnedTodo() 之後例外離開了 handler,測試從 403 掉成 500,這才裝 StatusPages 把它們接回來,第五輪的 Cors 要排在 Authentication 前面,理由不是 preflight,是錯誤回應也得帶 CORS header,最後一輪不寫實作,用完整 CRUD 流程跟結構化 404 兩個測試確認整條 pipeline 整合得起來
測試的 fullApp { } 也是一輪加一層,最後長成跟 main() 幾乎相同的設定
下一篇回到框架本身,聊效能與改進方向,Relix 用 JDK HttpServer 的瓶頸在哪、要怎麼量測、以及未來可以怎麼走得更遠
同步刊登於 Blog
圖片來源:AI 產生