iT邦幫忙

2026 iThome 鐵人賽

DAY 29
0
Software Development

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

Kotlin 手刻 Ktor 從零開始 Day 29 綜合實戰 (上),用 Relix 打造 Todo API

  • 分享至 

  • xImage
  •  

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

從第 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,這裡全部放在一起只是為了文章好讀

需求規格,Todo CRUD API

五個端點

方法 路徑 行為 成功 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 四件事一起往前推一格

  • GET list,順便把測試用的 app helper 架起來
  • POST 建立,接著把 response helper 的重複拿掉
  • GET 單一與找不到的 404
  • PUT 的 partial update
  • DELETE
  • 用一個測試確認五個端點兜在一起沒問題

程式碼分成三個新檔案,Todo.kt 放資料模型,TodoRepository.kt 放 TodoRepository 介面跟 InMemoryTodoRepository,TodoRoutes.kt 放 todoRoutes()。response helper 補進第 06 篇建立的 RelixCall.kt,測試全部放在同一個檔案 TodoApiTest.kt

TDD 第一輪,GET 空 list 回 200

第一輪要的行為很單純,沒有任何資料的時候,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 與 list()

先開 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 出來就是 [],第一輪通過

TDD 第二輪,POST 建立 Todo

第二輪是 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 只斷言「不是空字串」,因為時間戳每次跑都不一樣,斷言確切的值只會讓測試變脆

實作 create()

介面補一個方法,改的是 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 / created 的重複抽成 respond()

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 測試都只斷言狀態碼,所以這裡是靠人眼確認的,不是靠測試

TDD 第三輪,GET 單一與找不到的 404

第三輪兩個測試,一個確認「建立之後取得回同一筆」,一個確認「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() 與 notFound()

介面補 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 寫完,形狀已經固定下來了

  1. resolve<TodoRepository>() 從 DI 容器拿 repository
  2. 該 receive 就 receive,該 pathParam 就 pathParam
  3. 成功回 ok() 或 created(),null 就 notFound()

TDD 第四輪,PUT 的 partial update

第四輪三個測試,前兩個是 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()

介面補 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)
    }

TDD 第五輪,DELETE 與刪完再查

第五輪兩個測試,一個確認 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)
    }

實作 delete()

介面的最後一個方法,補進 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 有兩個、刪掉一個、確認 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 與 todoRoutes()

三塊程式碼逐輪長大,這裡各貼一次完整版,介面在 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")
            }
        }
    }
}

這個 in-memory 實作不建議真的拿去用

InMemoryTodoRepository 是拿來跑測試跟系列範例的,並行安全只做了一半,AtomicLong 本身是 thread-safe,所以 id 不會撞號,但 LinkedHashMap 不是,多個 request 同時寫入還是可能壞掉,而且 update() 的「讀出來 → copy → 寫回去」是一段 read-modify-write,就算換成 ConcurrentHashMap 也還是有 lost update 的問題,要用 compute() 這類原子操作或直接加鎖

真實應用要嘛用 ConcurrentHashMap + 原子操作,要嘛就交給具有交易語意的資料庫 repository,這篇的重點不在並行,但要知道問題在哪

DI 註冊與 app 組裝

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


上一篇
Kotlin 手刻 Ktor 從零開始 Day 28 Testing Utilities,框架內建的測試工具
下一篇
Kotlin 手刻 Ktor 從零開始 Day 30 綜合實戰 (下),加上驗證、錯誤處理與認證
系列文
Kotlin 手刻 Ktor 從零開始 共 32 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言