iT邦幫忙

2026 iThome 鐵人賽

DAY 30
0
Software Development

Kotlin 手刻 Ktor 從零開始系列 第 30 篇

Kotlin 手刻 Ktor 從零開始 Day 30 綜合實戰 (下),加上驗證、錯誤處理與認證

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260822/20121948NIqCtgaIUJ.png

第 29 篇把 Todo API 的 CRUD 跑起來了,距離「能上線」還有幾個必備零件

  • 輸入驗證,title 不能空白、長度要有上限
  • 統一錯誤輸出,所有錯誤回同一種 JSON 格式
  • 認證,只有帶 token 的人進得來
  • 授權,只能操作自己的 Todo
  • CORS,讓前端能跨域呼叫

五個零件,五輪紅綠循環

第 29 篇是一個端點一輪,這篇換成一個零件一輪,零件之間有依賴順序,驗證只碰 request body

授權要先知道「你是誰」,所以認證要排在授權前面,錯誤處理要等到有人真的開始丟例外才有事情做,CORS 則是最外層的守門員,放最後裝

  • 第一輪,輸入驗證,POST 的 title 不能空白也不能超過長度上限
  • 第二輪,認證,沒帶 token 跟 token 無效都進不來
  • 第三輪,授權的一半,資料隔離,Alice 看不到 Bob 的 Todo
  • 第四輪,授權的另一半,Alice 不能讀、不能改、不能刪 Bob 的 Todo,重構逼出 StatusPages
  • 第五輪,CORS,前端跨域打得進來
  • 最後一輪不加實作,用兩個測試確認整條 pipeline 兜起來沒問題

程式碼動的是第 29 篇那四個檔案,Todo.kt 的 Todo 加一個欄位,TodoRepository.kt 有兩個方法要換簽章,TodoRoutes.kt 除了改寫 todoRoutes() 之外還多了 createTodoValidator 跟 requireOwnedTodo(),app 組裝一樣在 main.kt。測試全部放在新的 TodoIntegrationTest.kt

測試的 app helper 也是逐輪長大,第一輪先把第 29 篇的 todoApp { } 原封搬過來改個名字,之後每一輪只加那一輪需要的 plugin,到第五輪才會是完整的設定

TDD 第一輪,POST 的輸入驗證

第一輪要的行為是空白的 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

實作 createTodoValidator

第 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 的時候情況就不一樣了

TDD 第二輪,沒有 token 就進不來

第二輪兩個測試,一個是完全沒帶 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 根本沒被保護起來

實作 Authentication 與 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 那邊的對應改法留到最後一節

TDD 第三輪,Alice 看不到 Bob 的 Todo

認證解決「你是誰」,授權解決「你能做什麼」,授權的第一件事是資料隔離,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 裡的東西全吐出來,根本不知道誰是誰

實作 ownerId 與依 owner 過濾的 list()

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 都沒有,例外會直接穿出去,整批報錯,舊的那份可以直接刪掉,它涵蓋的行為在最後一輪會用帶認證的版本再走一次

TDD 第四輪,Alice 不能動 Bob 的 Todo

看不到不等於動不了,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)

實作,先讓 delete 綠

最直接的寫法是在 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 兩個還是紅的

重構,三個 handler 的同一段檢查

要讓另外兩個也綠,最省事的做法是把那五行貼過去兩次,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 表達不了,才換成例外

例外飛出來了,裝 StatusPages 接住

重構完再跑一次,三個測試不是綠的,是三個 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 才會被踩到

TDD 第五輪,CORS 讓前端跨域

前端用 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

安裝 Cors,順序才是重點

第 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 header
  • Cors 也要在 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」這種測試

最後一輪,完整 CRUD 流程與結構化 404

最後一輪不加任何實作,兩個測試各自確認一件事,一個是五個端點帶著認證跑完一整輪,一個是錯誤回應的格式,兩個都是 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" }

全部的測試到這裡全部通過

  • 422,空 title、title 太長
  • 401,沒 token、無效 token
  • 403,Alice 不能讀、不能改、不能刪 Bob 的 todo
  • 資料隔離,Alice 看不到 Bob 的 todos
  • 完整 CRUD 流程 (帶 auth)
  • CORS preflight 204
  • CORS headers 出現在正常 response
  • 404 回結構化 JSON

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()

五輪各改一小塊,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 篇的版本比,改動有四處

  1. get { } 改成先拿 principal,再用 list(user.userId) 只列出自己的
  2. post { } 多一段 validation,不合法就回 422
  3. get("/{id}")、put、delete 都改成走 requireOwnedTodo(),一次處理「不存在 → 404」和「不是你的 → 403」
  4. delete 不用再自己判斷 Boolean,因為 requireOwnedTodo() 已經確認過東西存在

createTodoValidator 跟 requireOwnedTodo() 也在這個檔案裡,一個在 todoRoutes() 前面,一個在後面

完整 app 組裝

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 安裝順序

  1. ContentNegotiation,最底層,JSON 序列化反序列化
  2. CORS,要在 auth 之前,否則 auth 直接回的 401 身上不會有 CORS header
  3. StatusPages,攔截 exception 和 status code
  4. Authentication,Bearer token 驗證

跟測試的 fullApp { } 有兩處不一樣,第一處在這一段,多註冊了 ValidationException 的映射,測試裡的驗證失敗走的是 unprocessableEntity() 這條路,正式環境可能有別的地方會直接 throw

bearer { } 這裡還是那份寫死的 lookup table,跟測試一模一樣,這樣這份 main() 貼上去就跑得起來,後面那節的 curl 打的就是它,真的要上線,這幾行換成查資料庫或驗 JWT,UserPrincipal 的兩個參數照樣從查出來的使用者填

第二處是 /health,放在 authenticate { } 外面,不需要認證,監控系統打 health check 不用帶 token

跑起來,打一遍真的 server

第 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 產生


上一篇
Kotlin 手刻 Ktor 從零開始 Day 29 綜合實戰 (上),用 Relix 打造 Todo API
下一篇
Kotlin 手刻 Ktor 從零開始 Day 31 效能與改進方向,Relix 能走多遠 ?
系列文
Kotlin 手刻 Ktor 從零開始 共 32 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言