
從第 01 篇到第 28 篇,框架的核心零件都到齊了,Router、Pipeline、Plugin、Content Negotiation、Validation、Authentication、DI、StatusPages、TestKit,現在要把它們全部整合在一起,做一個「能代表框架成熟度」的實戰
本篇先聚焦 CRUD,把 API 規格定清楚、建立資料模型與 repository,用 relixTest { } 把五個端點開發出來,驗證、錯誤處理細節、認證會留到第 30 篇補齊
在第 01 篇提過的結構取捨,這兩篇繼續沿用,實作全部放 src/,測試全部放 test/,沒有 package 也沒有 module 邊界,真的要做一個 Todo 服務,Todo 這種 domain model、TodoRepository 這種資料存取、todoRoutes() 這種 HTTP 端點會各自待在不同的 package,甚至不同的 module,這裡全部放在一起只是為了文章好讀
五個端點
| 方法 | 路徑 | 行為 | 成功 Status |
|---|---|---|---|
| GET | /api/todos |
列出所有 Todo | 200 |
| POST | /api/todos |
建立新 Todo | 201 |
| GET | /api/todos/{id} |
取得單一 Todo | 200 |
| PUT | /api/todos/{id} |
更新 Todo | 200 |
| DELETE | /api/todos/{id} |
刪除 Todo | 204 |
回應一律 JSON (安裝 ContentNegotiation),id 用 String (內部用遞增數字轉字串,測試裡可預測)
新增一個檔案 Todo.kt,三個 data class 都放這裡
import kotlinx.serialization.Serializable
@Serializable
data class Todo(
val id: String,
val title: String,
val completed: Boolean,
val createdAt: String, // ISO 8601 字串
)
@Serializable
data class CreateTodoRequest(
val title: String,
)
@Serializable
data class UpdateTodoRequest(
val title: String? = null,
val completed: Boolean? = null,
)
為什麼分 CreateTodoRequest 和 UpdateTodoRequest ? 因為建立和更新的必填欄位不同,建立時 title 必填、completed 預設 false,更新時兩個都是選填,只更新有傳的欄位 (partial update)
createdAt 用 String 而不是 Instant,避免引入 java.time 的序列化轉換器,這裡就先用 Instant.now().toString() 產生 ISO 8601 格式就夠了
前面幾篇是一個零件一輪,這篇換成一個端點一輪,端點是天然的垂直切片,一個端點剛好對應一個 repository method,一輪就把測試、介面、實作、route 四件事一起往前推一格
程式碼分成三個新檔案,Todo.kt 放資料模型,TodoRepository.kt 放 TodoRepository 介面跟 InMemoryTodoRepository,TodoRoutes.kt 放 todoRoutes()。response helper 補進第 06 篇建立的 RelixCall.kt,測試全部放在同一個檔案 TodoApiTest.kt
第一輪要的行為很單純,沒有任何資料的時候,GET /api/todos 回 200 加上一個空陣列。但這一輪還要順便把測試的骨架搭好,新增 TodoApiTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
class TodoApiTest {
private fun todoApp(block: TestRelixApplication.() -> Unit) = relixTest {
install(ContentNegotiation) { json() }
services {
singleton<TodoRepository> { InMemoryTodoRepository() }
}
routing { todoRoutes() }
block()
}
@Test
fun `GET empty list returns 200 with empty array`() = todoApp {
val response = handleRequest(HttpMethod.Get, "/api/todos")
assertEquals(200, response.status)
val todos = response.jsonArray()
assertEquals(0, todos.size)
}
}
todoApp { } 這個 helper 是在第 28 篇的 relixTest { } 外面再包一層,把「裝 ContentNegotiation、註冊 repository、掛上 routing」這三件每個測試都要做的事集中在一個地方,測試 body 只關心「送什麼、期待什麼」,routing 那行只呼叫 todoRoutes(),接下來五輪 route 一直長大,helper 一次都不用改
現在整份測試連編譯都過不了,todoRoutes()、TodoRepository、InMemoryTodoRepository 三個都還不存在
先開 TodoRepository.kt,介面這一輪只需要一個方法
interface TodoRepository {
fun list(): List<Todo>
}
實作寫在同一個檔案
class InMemoryTodoRepository : TodoRepository {
private val store = LinkedHashMap<String, Todo>()
override fun list(): List<Todo> {
return store.values.toList()
}
}
store 用 LinkedHashMap 而不是普通的 HashMap,因為它保留插入順序,list 的輸出才會穩定,測試才有辦法斷言「第一個是誰」
最後是 route,新增 TodoRoutes.kt
fun RoutingBuilder.todoRoutes() {
route("/api") {
route("/todos") {
get {
val repo = resolve<TodoRepository>()
ok(repo.list())
}
}
}
}
把 routing 抽成 RoutingBuilder 的 extension function,app 那邊只要寫 app.routing { todoRoutes() },測試的 helper 也是同一行,兩邊掛的是同一份 route
resolve<TodoRepository>() 是第 26 篇的 DI,handler 不自己 new repository,跟誰要就從容器拿,ok() 是第 20 篇的泛型版本,會經過 Content Negotiation 把 List<Todo> 序列化成 JSON,空 list 出來就是 [],第一輪通過
第二輪是 POST,要確認的是 201、body 回的是建立好的 Todo、completed 預設 false、id 跟 createdAt 都有值
補進 TodoApiTest.kt,這個測試多用到 assertTrue,import 補上 kotlin.test.assertTrue
import kotlin.test.assertTrue
@Test
fun `POST creates todo and returns 201`() = todoApp {
val response = handleRequest(HttpMethod.Post, "/api/todos") {
setJsonBody("""{"title":"Buy milk"}""")
}
assertEquals(201, response.status)
val json = response.json()
assertEquals("Buy milk", json["title"])
assertEquals(false, json["completed"])
assertTrue((json["id"] as String).isNotEmpty())
assertTrue((json["createdAt"] as String).isNotEmpty())
}
id 跟 createdAt 只斷言「不是空字串」,因為時間戳每次跑都不一樣,斷言確切的值只會讓測試變脆
介面補一個方法,改的是 TodoRepository.kt
interface TodoRepository {
fun create(title: String): Todo
// ...
}
實作要多一個 id 產生器,InMemoryTodoRepository 補一個欄位跟一個方法,檔案最上面加兩個 import
import java.time.Instant
import java.util.concurrent.atomic.AtomicLong
class InMemoryTodoRepository : TodoRepository {
private val idCounter = AtomicLong(0)
override fun create(title: String): Todo {
val id = idCounter.incrementAndGet().toString()
val todo = Todo(
id = id,
title = title,
completed = false,
createdAt = Instant.now().toString(),
)
store[id] = todo
return todo
}
// ...
}
AtomicLong 產生遞增 id,第一個 Todo 的 id 是 "1",第二個是 "2",測試裡可以預測
為什麼用 atomic 而不是普通的 Long++,是因為兩個 request 同時進來的時候 ++ 會撞號
route 補在 TodoRoutes.kt 的 get { } 後面
post {
val req = receive<CreateTodoRequest>()
val repo = resolve<TodoRepository>()
val todo = repo.create(req.title)
created(todo)
}
receive<CreateTodoRequest>() 是第 21 篇的 request body 解析,走 Content Negotiation 把 JSON 轉成 data class,這篇沒有裝 StatusPages,body 格式壞掉的時候 exception 會直接穿出去,錯誤處理第 30 篇會補上
created() 第 21 篇已經做好了,泛型版本跟 ok() 的實作幾乎一模一樣,只有 status code 不同,這一輪直接拿來用就通過了
ok<T>() 跟 created<T>() 是第 20、21 篇分兩次做的,兩份程式碼除了 status code 之外幾乎相同,第三輪還要再加一個 notFound(),在繼續疊上去之前,先把重複的部分抽掉
真的動手才看得出來,兩份不是完全一樣,406 那條分支對不上,第 20 篇的 ok() 回的是 mapOf("Content-Type" to listOf("text/plain")),第 21 篇的 created() 回的是 mapOf(),created() 當初漏了 Content-Type,抽共用的時候要挑一個,這裡挑帶 header 的那個版本,因為第 20 篇有一段 curl 輸出印著 Content-type: text/plain,改成不帶 header 的話那段輸出就對不上了
改的是 RelixCall.kt,新增一個 respond(),原本兩個泛型版本改成委派給它
class RelixCall(
val application: RelixApplication,
val request: RelixRequest,
internal var pathParams: Map<String, String> = emptyMap(),
internal var matchedRoute: Route? = null,
) {
inline fun <reified T : Any> respond(status: Int, value: T): RelixResponse {
val converter = application.converterRegistry.select(request.header("Accept"))
?: return RelixResponse(
406,
mapOf("Content-Type" to listOf("text/plain")),
"Not Acceptable".toByteArray(),
)
val body = converter.encode(value, typeOf<T>())
return RelixResponse(
status,
mapOf("Content-Type" to listOf("${converter.contentType}; charset=utf-8")),
body,
)
}
// 第 20、21 篇的泛型版本改成委派給 respond()
inline fun <reified T : Any> ok(value: T): RelixResponse = respond(200, value)
inline fun <reified T : Any> created(value: T): RelixResponse = respond(201, value)
// 字串多載仍然保留,理由見第 20、21 篇
fun ok(text: String): RelixResponse = RelixResponse(
200,
mapOf("Content-Type" to listOf("text/plain; charset=utf-8")),
text.toByteArray(Charsets.UTF_8),
)
fun created(text: String): RelixResponse = RelixResponse(
201,
mapOf("Content-Type" to listOf("text/plain; charset=utf-8")),
text.toByteArray(Charsets.UTF_8),
)
// ...
}
ok() 跟 created() 的四個多載連同 respond() 都留在 RelixCall 類別裡面,respond() 要讀 application 跟 request,非得是成員函式不可,這輪沒有新測試,前兩輪就是安全網,抽完再跑一次,兩個都要照樣通過
唯一真的變了的是 created() 的 406,從此多帶一個 Content-Type。這個改動沒有測試蓋到,第 20、21 篇的 406 測試都只斷言狀態碼,所以這裡是靠人眼確認的,不是靠測試
第三輪兩個測試,一個確認「建立之後取得回同一筆」,一個確認「id 不存在回 404,而且錯誤 body 是結構化 JSON」,前者要先 POST 再 GET,這是第一個跨兩個請求的測試,同一個 todoApp { } 裡面的兩次 handleRequest() 共用同一個 app,也共用同一份 repository,兩個測試一樣補進 TodoApiTest.kt
@Test
fun `GET single todo returns created todo`() = todoApp {
// 先建立
val createRes = handleRequest(HttpMethod.Post, "/api/todos") {
setJsonBody("""{"title":"Buy milk"}""")
}
val id = createRes.json()["id"] as String
// 再取回
val getRes = handleRequest(HttpMethod.Get, "/api/todos/$id")
assertEquals(200, getRes.status)
assertEquals("Buy milk", getRes.json()["title"])
}
@Test
fun `GET nonexistent todo returns 404`() = todoApp {
val response = handleRequest(HttpMethod.Get, "/api/todos/999")
assertEquals(404, response.status)
val json = response.json()
assertEquals(404, json["status"])
assertTrue((json["message"] as String).isNotEmpty())
}
id 是從 POST 的回應讀出來的,不是寫死 "1",這樣就算之後 id 的產生規則換掉,測試也不用改
第二個測試除了狀態碼還斷言了 body 的形狀,這一步不能省。只看 404 的話,第 05 篇那個回 text/plain 的 top-level notFound() 也是 404,測試照樣綠燈,等一下要做的東西就等於沒被守著。加上 status 跟 message 兩個欄位的斷言,測試才真的在要求「錯誤回應是結構化 JSON」這件事
介面補 get(),回傳型別是 Todo?,補進 TodoRepository.kt,排在 list() 後面,查詢類的方法放一起
interface TodoRepository {
fun get(id: String): Todo?
// ...
}
實作補在同一個檔案的 list() 後面,內容就是查 map
override fun get(id: String): Todo? {
return store[id]
}
route 那邊要處理 null,補進 TodoRoutes.kt
get("/{id}") {
val repo = resolve<TodoRepository>()
val todo = repo.get(pathParam("id"))
?: return@get notFound("Todo not found")
ok(todo)
}
return@get notFound(...) 這個寫法在第 21 篇解釋過,handler 的回傳型別是 RelixResponse,用 labeled return 提早離開 lambda
回結構化 JSON 的 notFound() 現在還沒有,這就是剛才那兩行 body 斷言在逼的東西,補進 RelixCall.kt,它沒有序列化需求,寫成 extension function 就好,跟第 27 篇的 errorResponse() 同一招
fun RelixCall.notFound(message: String): RelixResponse =
errorResponse(404, message)
它走的是第 27 篇的 errorResponse(),產生統一的 JSON 錯誤格式 {"status":404,"message":"..."},為什麼這個要掛在 RelixCall 上 ? 不是因為它要讀 call 的狀態,是因為 errorResponse() 在第 27 篇為了 StatusPages 的 handler receiver 做成了 RelixCall 的 extension,包在它外面的 notFound() 只能跟著掛在同一個 receiver 上
要注意它跟第 05 篇那個 top-level 的 notFound() 是兩個不同的東西,這個要帶 message,第 05 篇那個有預設值,所以 pipeline 裡 router 找不到路由時寫的 notFound() 仍然走 top-level 版本,回的是 text/plain
三個 handler 寫完,形狀已經固定下來了
resolve<TodoRepository>() 從 DI 容器拿 repositoryok() 或 created(),null 就 notFound()
第四輪三個測試,前兩個是 partial update 的兩面,只傳 title 的時候 completed 要維持原值,只傳 completed 的時候 title 要維持原值,第三個是不存在的 id,三個都補進 TodoApiTest.kt
@Test
fun `PUT updates todo title`() = todoApp {
// 建立
val createRes = handleRequest(HttpMethod.Post, "/api/todos") {
setJsonBody("""{"title":"Buy milk"}""")
}
val id = createRes.json()["id"] as String
// 更新 title
val updateRes = handleRequest(HttpMethod.Put, "/api/todos/$id") {
setJsonBody("""{"title":"Buy eggs"}""")
}
assertEquals(200, updateRes.status)
assertEquals("Buy eggs", updateRes.json()["title"])
assertEquals(false, updateRes.json()["completed"]) // 沒傳 completed,維持原值
}
@Test
fun `PUT updates completed status`() = todoApp {
val createRes = handleRequest(HttpMethod.Post, "/api/todos") {
setJsonBody("""{"title":"Buy milk"}""")
}
val id = createRes.json()["id"] as String
val updateRes = handleRequest(HttpMethod.Put, "/api/todos/$id") {
setJsonBody("""{"completed":true}""")
}
assertEquals(200, updateRes.status)
assertEquals("Buy milk", updateRes.json()["title"]) // title 沒傳,維持原值
assertEquals(true, updateRes.json()["completed"])
}
@Test
fun `PUT nonexistent todo returns 404`() = todoApp {
val response = handleRequest(HttpMethod.Put, "/api/todos/999") {
setJsonBody("""{"title":"nope"}""")
}
assertEquals(404, response.status)
}
那兩行註解就是 partial update 的重點,request body 沒提到的欄位不能被清空
介面補 update(),一樣是 nullable 回傳,補進 TodoRepository.kt
interface TodoRepository {
fun update(id: String, req: UpdateTodoRequest): Todo?
// ...
}
實作用 data class 的 copy()
override fun update(id: String, req: UpdateTodoRequest): Todo? {
val existing = store[id] ?: return null
val updated = existing.copy(
title = req.title ?: existing.title,
completed = req.completed ?: existing.completed,
)
store[id] = updated
return updated
}
req.title ?: existing.title 就是「有傳就更新,沒傳就保留」,UpdateTodoRequest 兩個欄位都是 nullable 而且有預設值 null,client 沒送的欄位解出來就是 null,elvis 運算子接住它換成舊值,copy() 讓 Todo 可以維持 immutable,改一個欄位是產生一個新物件,不是就地改寫
route 補進 TodoRoutes.kt,跟 get("/{id}") 是同一個形狀,只多一個 receive()
put("/{id}") {
val req = receive<UpdateTodoRequest>()
val repo = resolve<TodoRepository>()
val todo = repo.update(pathParam("id"), req)
?: return@put notFound("Todo not found")
ok(todo)
}
第五輪兩個測試,一個確認 DELETE 回 204,一個確認刪掉之後再 GET 會拿到 404,後者才是真的在確認「東西不見了」,只看 204 有可能 handler 根本沒刪,一樣補進 TodoApiTest.kt
@Test
fun `DELETE returns 204`() = todoApp {
val createRes = handleRequest(HttpMethod.Post, "/api/todos") {
setJsonBody("""{"title":"Buy milk"}""")
}
val id = createRes.json()["id"] as String
val deleteRes = handleRequest(HttpMethod.Delete, "/api/todos/$id")
assertEquals(204, deleteRes.status)
}
@Test
fun `GET after DELETE returns 404`() = todoApp {
val createRes = handleRequest(HttpMethod.Post, "/api/todos") {
setJsonBody("""{"title":"Buy milk"}""")
}
val id = createRes.json()["id"] as String
handleRequest(HttpMethod.Delete, "/api/todos/$id")
val getRes = handleRequest(HttpMethod.Get, "/api/todos/$id")
assertEquals(404, getRes.status)
}
介面的最後一個方法,補進 TodoRepository.kt
interface TodoRepository {
fun delete(id: String): Boolean
// ...
}
實作一樣補進 TodoRepository.kt,靠 remove() 的回傳值判斷有沒有刪到東西
override fun delete(id: String): Boolean {
return store.remove(id) != null
}
delete() 回的是 Boolean 不是 Unit,這樣 handler 才分得出「刪成功」跟「東西本來就不存在」,前者回 204 後者回 404
route 補進 TodoRoutes.kt
delete("/{id}") {
val repo = resolve<TodoRepository>()
val deleted = repo.delete(pathParam("id"))
if (deleted) noContent() else notFound("Todo not found")
}
noContent() 不用做,第 05 篇的工廠方法那組就有了,RelixResponse 的 headers 跟 body 都有預設值,所以那邊只寫了 RelixResponse(statusCode = 204),204 No Content 本來就不帶 body,也不需要 Content-Type,它連參數都不用,更不必讀 call 的狀態,留在 top-level 就好,這輪直接呼叫
這些 helper 讓 handler 的意圖更明確,created(todo) 比 RelixResponse(201, ...) 好讀得多
前面五輪各自確認一個端點,最後補一個測試把它們串起來,建立兩個、確認 list 有兩個、刪掉一個、確認 list 剩一個,這是 TodoApiTest.kt 的最後一個測試
@Test
fun `list reflects all operations`() = todoApp {
// 建立兩個
handleRequest(HttpMethod.Post, "/api/todos") {
setJsonBody("""{"title":"Todo 1"}""")
}
handleRequest(HttpMethod.Post, "/api/todos") {
setJsonBody("""{"title":"Todo 2"}""")
}
// list 應該有 2 個
val listRes = handleRequest(HttpMethod.Get, "/api/todos")
assertEquals(2, listRes.jsonArray().size)
// 刪除第一個
val firstId = (listRes.jsonArray()[0] as Map<*, *>)["id"] as String
handleRequest(HttpMethod.Delete, "/api/todos/$firstId")
// list 應該剩 1 個
val afterDelete = handleRequest(HttpMethod.Get, "/api/todos")
assertEquals(1, afterDelete.jsonArray().size)
}
這一輪沒有新實作,寫完直接就通過,它要確認的是五個端點共用同一份 repository 狀態,而且 LinkedHashMap 的順序有維持住,jsonArray()[0] 拿到的確實是先建立的那一個
三塊程式碼逐輪長大,這裡各貼一次完整版,介面在 TodoRepository.kt
interface TodoRepository {
fun list(): List<Todo>
fun get(id: String): Todo?
fun create(title: String): Todo
fun update(id: String, req: UpdateTodoRequest): Todo?
fun delete(id: String): Boolean
}
五個方法對應五個端點,get 和 update 回 null 表示找不到,delete 回 Boolean 表示是否有刪到東西
為什麼不把 repository 做成 suspend ? 因為現在是 in-memory 實作,沒有 I/O 要等,加了也只是多打五個字,換成資料庫的時候,介面跟五個 override fun 一起加上 suspend,呼叫端本來就在 suspend handler 裡不用動,這幾個測試也不用動,因為它們打的是 HTTP,沒有直接碰 repository
import java.time.Instant
import java.util.concurrent.atomic.AtomicLong
class InMemoryTodoRepository : TodoRepository {
private val store = LinkedHashMap<String, Todo>()
private val idCounter = AtomicLong(0)
override fun list(): List<Todo> {
return store.values.toList()
}
override fun get(id: String): Todo? {
return store[id]
}
override fun create(title: String): Todo {
val id = idCounter.incrementAndGet().toString()
val todo = Todo(
id = id,
title = title,
completed = false,
createdAt = Instant.now().toString(),
)
store[id] = todo
return todo
}
override fun update(id: String, req: UpdateTodoRequest): Todo? {
val existing = store[id] ?: return null
val updated = existing.copy(
title = req.title ?: existing.title,
completed = req.completed ?: existing.completed,
)
store[id] = updated
return updated
}
override fun delete(id: String): Boolean {
return store.remove(id) != null
}
}
route 在 TodoRoutes.kt
fun RoutingBuilder.todoRoutes() {
route("/api") {
route("/todos") {
get {
val repo = resolve<TodoRepository>()
ok(repo.list())
}
post {
val req = receive<CreateTodoRequest>()
val repo = resolve<TodoRepository>()
val todo = repo.create(req.title)
created(todo)
}
get("/{id}") {
val repo = resolve<TodoRepository>()
val todo = repo.get(pathParam("id"))
?: return@get notFound("Todo not found")
ok(todo)
}
put("/{id}") {
val req = receive<UpdateTodoRequest>()
val repo = resolve<TodoRepository>()
val todo = repo.update(pathParam("id"), req)
?: return@put notFound("Todo not found")
ok(todo)
}
delete("/{id}") {
val repo = resolve<TodoRepository>()
val deleted = repo.delete(pathParam("id"))
if (deleted) noContent() else notFound("Todo not found")
}
}
}
}
InMemoryTodoRepository 是拿來跑測試跟系列範例的,並行安全只做了一半,AtomicLong 本身是 thread-safe,所以 id 不會撞號,但 LinkedHashMap 不是,多個 request 同時寫入還是可能壞掉,而且 update() 的「讀出來 → copy → 寫回去」是一段 read-modify-write,就算換成 ConcurrentHashMap 也還是有 lost update 的問題,要用 compute() 這類原子操作或直接加鎖
真實應用要嘛用 ConcurrentHashMap + 原子操作,要嘛就交給具有交易語意的資料庫 repository,這篇的重點不在並行,但要知道問題在哪
main.kt 裡面把零件接起來
fun main() {
val app = Relix {
development = true
}
app.install(ContentNegotiation) { json() }
app.services {
singleton<TodoRepository> { InMemoryTodoRepository() }
}
app.routing { todoRoutes() }
app.start()
}
四個步驟,建立 app、裝 ContentNegotiation、註冊 repository、掛上 routing,TodoRepository 註冊成 singleton,整個 app 共用一個 instance,handler 用 resolve<TodoRepository>() 拿到
這裡跟測試的 todoApp { } 是同一份設定,差別只在測試不需要 app.start(),直接把 request 送進 app.handle() 就好,所以測試涵蓋到的行為,main() 跑起來也是同一套
main() 跑起來,五個端點照 CRUD 的順序打一遍
建立
curl -i -X POST localhost:8080/api/todos \
-H 'Content-Type: application/json' \
-d '{"title":"Buy milk"}'
HTTP/1.1 201 Created
Content-type: application/json; charset=utf-8
Content-length: 89
{"id":"1","title":"Buy milk","completed":false,"createdAt":"2026-08-21T05:49:42.478076Z"}
201 是 created(todo) 帶出來的,body 是建好的整筆 Todo,completed 沒傳就是 false,id 是 "1",createdAt 是 Instant.now().toString() 產生的 ISO 8601 字串,JSON 的欄位順序跟 Todo 的宣告順序一致,這是 kotlinx.serialization 的行為
header 印出來是 Content-type 而不是程式裡寫的 Content-Type,這是 JDK HttpServer 的正規化
列出
再打一次同一條 POST,然後查 list
curl -i localhost:8080/api/todos
HTTP/1.1 200 OK
Content-type: application/json; charset=utf-8
Content-length: 181
[{"id":"1","title":"Buy milk","completed":false,"createdAt":"2026-08-21T05:49:42.478076Z"},{"id":"2","title":"Buy milk","completed":false,"createdAt":"2026-08-21T05:49:55.965999Z"}]
兩筆的 title 一樣,id 卻是 "1" 跟 "2",這是 AtomicLong 在遞增。"1" 排在 "2" 前面,LinkedHashMap 的插入順序在真實 server 上也維持住了,跟 list reflects all operations 那個測試斷言的是同一件事
取單一
curl -i localhost:8080/api/todos/1
HTTP/1.1 200 OK
Content-type: application/json; charset=utf-8
Content-length: 89
{"id":"1","title":"Buy milk","completed":false,"createdAt":"2026-08-21T05:49:42.478076Z"}
/{id} 走的是第 09 篇的 path parameter,pathParam("id") 拿到的就是網址上那個 1
只更新 completed
curl -i -X PUT localhost:8080/api/todos/1 \
-H 'Content-Type: application/json' \
-d '{"completed":true}'
HTTP/1.1 200 OK
Content-type: application/json; charset=utf-8
Content-length: 88
{"id":"1","title":"Buy milk","completed":true,"createdAt":"2026-08-21T05:49:42.478076Z"}
request body 只有 completed,回來的 title 還是 Buy milk,這就是 req.title ?: existing.title 在真實請求上的樣子,沒提到的欄位沒被清空
刪掉再查
curl -i -X DELETE localhost:8080/api/todos/2
HTTP/1.1 204 No Content
204 連 Content-length 都沒有,noContent() 回的 RelixResponse body 是空的,JDK HttpServer 對 204 就不補 body 也不補長度
同一個 id 再查一次
curl -i localhost:8080/api/todos/2
HTTP/1.1 404 Not Found
Content-type: application/json; charset=utf-8
Content-length: 41
{"status":404,"message":"Todo not found"}
404 的 body 是結構化 JSON 不是 text/plain,這就是第三輪那兩行 body 斷言逼出來的 notFound(message),跟第 05 篇那個回 text/plain 的 top-level 版本是兩條不同的路
為什麼 POST 回 201 而不是 200 ?
HTTP 語意,201 Created 表示「伺服器建立了新資源」,200 OK 是通用的成功回應,用 201 讓 client 不用 parse body 就知道「東西建立成功了」,DELETE 用 204 No Content 同理,「操作成功,沒有 body 要回」
partial update 用 PUT 還是 PATCH ?
嚴格來說,PUT 是「整個替換」,PATCH 是「部分更新」,這裡用 PUT + nullable 欄位做 partial update,因為 PATCH 的語意 (JSON Patch、JSON Merge Patch) 引入的複雜度跟目標不成比例,真實的 API 可以用 PATCH
JSON Patch 用 operation list 描述變更,JSON Merge Patch 直接傳 partial object,並把 null 定義成移除欄位,真的要改用 PATCH,得先挑好規格、定好 null 的語意,不是把 method 字串換掉就算數
為什麼 createdAt 用 String 不用 Instant ?
這裡用 ISO 8601 String,少掉日期時間 serializer 的設定,正式實作建議內部使用 Instant,並用 serializer 固定 wire format,只要 JSON 字串格式不變,client contract 就能維持相容
為什麼 repository 回 null 而不是丟 exception ?
因為「找不到」不是例外狀況,repo.get("999") 回 null,handler 自己決定要回 404 還是做別的事,如果丟 exception,handler 的控制流就被打斷了,你得靠 StatusPages 去 catch,但 404 的 message 可能因 endpoint 不同而不一樣
五個端點分成五次循環,每一次只推一個端點,測試、TodoRepository 介面、InMemoryTodoRepository、todoRoutes() 四個一起長大,中間停一次把 ok() 跟 created() 的重複抽成 respond(),最後一輪不寫實作,只確認五個端點整合起來的狀態是對的
handler 全部是同一個形狀,resolve<TodoRepository>() 拿 repository、receive<T>() 解析 body、ok()/created()/noContent()/notFound() 回應
下一篇為 Todo API 加上驗證、StatusPages、Authentication 和 CORS,建立完整的整合測試套件
同步刊登於 Blog
圖片來源:AI 產生