iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
Software Development

Kotlin 手刻 Ktor 從零開始系列 第 21

Kotlin 手刻 Ktor 從零開始 Day 21 型別安全的 Request 讀取,receive<T>() 與 queryParam<T>()

  • 分享至 

  • xImage
  •  

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

第 20 篇做了 Response 方向,ok(user) 自動轉 JSON,這篇補齊 Request 的方向,讓 handler 用型別安全的方式讀取輸入

目標是三組 API

  • receive<T>(),把 request body 反序列化成指定型別
  • queryParam<T>()queryParamOrNull<T>(),把 query parameter 轉成指定型別,前者必填、後者選填
  • created(value),跟第 20 篇的 ok(value) 一樣走 content negotiation,差別只在回的是 201

做完之後,handler 會變這樣

post("/users") {
    val req = receive<CreateUserRequest>()
    val user = service.create(req)
    created(user)
}

get("/users") {
    val keyword = queryParam<String>("keyword")      // 必填,沒帶就回 400
    val page = queryParamOrNull<Int>("page") ?: 1    // 選填,沒帶就用預設值
    ok(service.search(keyword, page))
}

沒有手動解析 JSON、沒有手動轉型、錯誤處理直接交給框架,這篇會把 Content Negotiation 的部份給收尾

TDD 先確認 selectByContentType() 的比對規則

這篇的兩個 API 都要先問 registry 同一件事,「這個格式我認得嗎」,所以跟第 20 篇一樣從最底層開始,先把 registry 補齊,再往上做 receive<T>()

第 20 篇的 select() 是根據 Accept header 選 converter,那是 Response 方向,Request 方向要看的是 Content-Type,兩個方向的規則不一樣,不能共用同一個方法,所以要在 ConverterRegistry 上加一個 selectByContentType()

這是 registry 上的純函式,接收一個字串、回一個 converter 或 null,跟第 20 篇的 select() 一樣不用起 server 也不用 TestKit,測試補進既有的 ConverterRegistryTest.kt,那邊的 FakeConverterregistry() 直接拿來用

@Test
fun `null content type returns null`() {
    assertNull(registry().selectByContentType(null))
}

@Test
fun `explicit content type selects the matching converter`() {
    assertSame(xmlConverter, registry().selectByContentType("application/xml"))
}

@Test
fun `charset parameter is stripped before matching`() {
    assertSame(
        jsonConverter,
        registry().selectByContentType("application/json; charset=utf-8"),
    )
}

@Test
fun `unsupported content type returns null`() {
    assertNull(registry().selectByContentType("text/html"))
}

@Test
fun `wildcard is not treated as a default`() {
    assertNull(registry().selectByContentType("*/*"))
}

charset parameter is stripped before matching 是這五個裡面最常實際遇到的,client 送的 Content-Type 幾乎都會帶 ; charset=utf-8,不先把 ; 後面的參數拿掉,字串比對永遠不會相等,跟第 20 篇 select() 要處理 ;q=0.9 是同一類問題,差別在一個是 quality factor、一個是 charset

最後一個要注意,select() 收到 */* 會回第一個 converter,selectByContentType() 收到 */* 直接回 null,這是兩個方向刻意的不同,Accept: */* 的意思是「你決定就好」,但 Content-Type: */* 沒有意義,client 送 request 的時候一定知道自己送的是什麼格式,與其猜一個,不如回 null 讓呼叫端丟 415

實作 selectByContentType()

測試寫完,實作補進 ConverterRegistry.kt

fun selectByContentType(contentType: String?): RelixConverter? {
    if (contentType == null) {
        return null
    }

    val mediaType = contentType.substringBefore(";").trim()
    return converters.firstOrNull { it.contentType == mediaType }
}

substringBefore(";")application/json; charset=utf-8 切成 application/jsontrim() 處理分號前後可能留下的空白,*/* 不需要特別寫程式碼擋,它本來就不會出現在 converters 裡,firstOrNull 自然就回 null

TDD 先確認 receive<T>() 的行為

底層備齊了,接下來是把三個元件串起來的 receive<T>()

  1. Request body (第 19 篇的 body: ByteArray + body caching)
  2. Content-Type 比對 (上一節的 selectByContentType())
  3. 反序列化 (第 20 篇的 RelixConverter.decode())

三個元件散在不同層,plugin 要有裝、routing 要走得到、body 要進得來,整條路徑才走得完,所以跟第 20 篇的 ok(value) 一樣,這裡要回到 TestKit 寫整合測試,測試檔案放 ReceiveTest.kt

import kotlinx.serialization.Serializable
import kotlin.test.Test
import kotlin.test.assertEquals

@Serializable
data class CreateUserRequest(val name: String, val age: Int)

class ReceiveTest {

    private fun createApp(): RelixApplication {
        val app = RelixApplication()
        app.install(ErrorHandling)
        app.install(ContentNegotiation) { json() }
        app.routing {
            post("/users") {
                val req = receive<CreateUserRequest>()
                ok("${req.name}:${req.age}")
            }
        }
        return app
    }

    @Test
    fun `receive deserializes json body`() {
        val testKit = RelixTestKit(createApp())

        val response = testKit.handleRequest(
            method = "POST",
            path = "/users",
            headers = mapOf("Content-Type" to listOf("application/json")),
            body = """{"name": "Relix", "age": 1}""".toByteArray(),
        )

        assertEquals(200, response.statusCode)
        assertEquals("Relix:1", response.body.toString(Charsets.UTF_8))
    }

    @Test
    fun `content type with charset still selects the converter`() {
        val testKit = RelixTestKit(createApp())

        val response = testKit.handleRequest(
            method = "POST",
            path = "/users",
            headers = mapOf("Content-Type" to listOf("application/json; charset=utf-8")),
            body = """{"name": "小明", "age": 2}""".toByteArray(),
        )

        assertEquals(200, response.statusCode)
        assertEquals("小明:2", response.body.toString(Charsets.UTF_8))
    }

    @Test
    fun `receive with bad json returns 400`() {
        val testKit = RelixTestKit(createApp())

        val response = testKit.handleRequest(
            method = "POST",
            path = "/users",
            headers = mapOf("Content-Type" to listOf("application/json")),
            body = """not valid json""".toByteArray(),
        )

        assertEquals(400, response.statusCode)
    }

    @Test
    fun `receive with wrong type returns 400`() {
        val testKit = RelixTestKit(createApp())

        val response = testKit.handleRequest(
            method = "POST",
            path = "/users",
            headers = mapOf("Content-Type" to listOf("application/json")),
            body = """{"name": 123, "age": "not a number"}""".toByteArray(),
        )

        assertEquals(400, response.statusCode)
    }

    @Test
    fun `receive with missing field returns 400`() {
        val testKit = RelixTestKit(createApp())

        val response = testKit.handleRequest(
            method = "POST",
            path = "/users",
            headers = mapOf("Content-Type" to listOf("application/json")),
            body = """{"name": "Relix"}""".toByteArray(),
        )

        assertEquals(400, response.statusCode)
    }

    @Test
    fun `receive with missing content-type returns 415`() {
        val testKit = RelixTestKit(createApp())

        val response = testKit.handleRequest(
            method = "POST",
            path = "/users",
            body = """{"name": "Relix", "age": 1}""".toByteArray(),
        )

        assertEquals(415, response.statusCode)
    }

    @Test
    fun `receive with unsupported content-type returns 415`() {
        val testKit = RelixTestKit(createApp())

        val response = testKit.handleRequest(
            method = "POST",
            path = "/users",
            headers = mapOf("Content-Type" to listOf("text/xml")),
            body = "<user/>".toByteArray(),
        )

        assertEquals(415, response.statusCode)
    }
}

createApp() 裡面那行 install(ErrorHandling) 不是裝好看的,receive<T>() 打算用 throw 表達錯誤,丟的是第 16 篇那個 RelixHttpException,沒有 middleware 在外面接住的話,exception 會一路往外穿出去,測試拿到的不是 400 也不是 415,而是直接爆掉,錯誤碼要能被斷言,前提是有人負責把 exception 翻成 response

七個測試分成三組看

第一組是兩個成功的案例,receive deserializes json body 走最單純的路徑,content type with charset still selects the converter 則是把 ; charset=utf-8 加回去,這個測試看起來瑣碎,但實務上 client 送的 Content-Type 幾乎都會帶這一段,前面 selectByContentType() 要是沒把參數拿掉,這個測試就會變成 415,順便用中文字驗一次 UTF-8 有沒有正確進出

第二組是三個 400,壞掉的 JSON、型別對不上、少一個欄位,這三種都是「格式我認識,但內容有問題」,{"name": 123, "age": "not a number"} 兩個欄位的型別剛好互換,{"name": "Relix"} 則是少了 agekotlinx.serialization 對這三種情況丟的 exception 不一樣,但都在同一個家族底下,實作那節會講怎麼一次攔完

第三組是兩個 415,完全沒送 Content-Type 跟送了一個沒註冊的格式,兩個走的是同一條路,registry 找不到 converter 就回 null

400 和 415 的分界很重要,415 表示「我不認識這個格式」,400 表示「格式我認識,但內容有問題」,client 收到 415 知道要改 Content-Type,收到 400 知道要改 body,全部回 400 的話,client 只能自己猜是哪一種

實作 receive<T>()

測試把行為講完了,把 receive<T>() 寫出來,下面的程式碼加到 RelixCall 類別裡

import kotlinx.serialization.SerializationException

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> receive(): T {
            val contentType = request.header("Content-Type")
            val converter = application.converterRegistry.selectByContentType(contentType)
                ?: throw RelixHttpException(415, "Unsupported Media Type: $contentType")

            return try {
                converter.decode(request.body, typeOf<T>()) as T
            } catch (e: SerializationException) {
                throw RelixHttpException(400, "Bad Request: ${e.message}")
            }
        }

        // ...
    }

inline + reifiedtypeOf<T>() 在呼叫端展開,跟第 20 篇的 ok() 是同一個機制,compiler 會把泛型型別資訊保留下來,converter 才能拿到完整的 KType

「reified」可以理解成具體化,一般的泛型函式不能把非 reified 的 T 傳給 typeOf<T>(),標記 reified 之後,compiler 能在 inline 的呼叫點保留實際型別,因此可以取得 UserList<Int> 對應的完整 KType,reified 函式通常只負責取得型別,再把工作交給一般函式,避免每個呼叫點展開過多 bytecode

錯誤處理分兩層,剛好對應測試的第二組跟第三組

  • Content-Type 不支援 → 415 Unsupported Media Type
  • 解析或型別轉換失敗 → 400 Bad Request

try/catch 只攔 SerializationException,這一個型別就把三個 400 的測試都涵蓋了,而 JSON 格式壞掉丟的是 JsonDecodingException,欄位缺了丟的是 MissingFieldException,兩個都是 SerializationException 的子類別,型別對不上也走同一條,攔父類別就不用一個一個列

其他程式錯誤不攔,繼續往外丟,交給 error handling middleware 回 500,handler 裡自己寫的 bug 不應該被包裝成 400 送給 client

TDD 先確認 queryParam 的轉型規則

第 04 篇做了 parseQuery() 把 query string 解析成 Map<String, List<String>>,但在 handler 裡還是要自己轉型

// 不好:手動轉型
val page = request.queryParameters["page"]?.firstOrNull()?.toIntOrNull() ?: 1

?.firstOrNull()?.toIntOrNull() ?: 1 這一串每個 handler 都要再寫一次,而且 ?: 1 把兩件事混在一起,參數沒帶跟參數帶了但不是數字,最後都變成 1,client 打錯字也不會有人告訴它

要做的是三個函式,queryParam<T>() 必填、queryParamOrNull<T>() 選填,加上一個共用的轉型邏輯,測試檔案放 QueryParamTest.kt

import kotlin.test.Test
import kotlin.test.assertEquals

class QueryParamTest {

    private fun newApp(): RelixApplication {
        val app = RelixApplication()
        app.install(ErrorHandling)
        return app
    }

    @Test
    fun `queryParam Int success`() {
        val app = newApp()
        app.routing {
            get("/items") {
                val page = queryParam<Int>("page")
                ok("page=$page")
            }
        }

        val testKit = RelixTestKit(app)
        // RelixTestKit 目前不會幫你拆 query string,要用 queryParameters 直接給
        // (第 28 篇的 TestRequestBuilder 才會支援 "/items?page=2" 這種寫法)
        val response = testKit.handleRequest(
            method = "GET",
            path = "/items",
            queryParameters = mapOf("page" to listOf("2")),
        )

        assertEquals(200, response.statusCode)
        assertEquals("page=2", response.body.toString(Charsets.UTF_8))
    }

    @Test
    fun `queryParam missing returns 400`() {
        val app = newApp()
        app.routing {
            get("/items") {
                val page = queryParam<Int>("page")
                ok("page=$page")
            }
        }

        val testKit = RelixTestKit(app)
        val response = testKit.handleRequest("GET", "/items")

        assertEquals(400, response.statusCode)
    }

    @Test
    fun `queryParam invalid value returns 400`() {
        val app = newApp()
        app.routing {
            get("/items") {
                val page = queryParam<Int>("page")
                ok("page=$page")
            }
        }

        val testKit = RelixTestKit(app)
        val response = testKit.handleRequest(
            method = "GET",
            path = "/items",
            queryParameters = mapOf("page" to listOf("abc")),
        )

        assertEquals(400, response.statusCode)
    }

    @Test
    fun `queryParamOrNull returns null when missing`() {
        val app = newApp()
        app.routing {
            get("/items") {
                val search = queryParamOrNull<String>("search")
                ok("search=${search ?: "none"}")
            }
        }

        val testKit = RelixTestKit(app)
        val response = testKit.handleRequest("GET", "/items")

        assertEquals(200, response.statusCode)
        assertEquals("search=none", response.body.toString(Charsets.UTF_8))
    }

    @Test
    fun `queryParamOrNull converts when present`() {
        val app = newApp()
        app.routing {
            get("/items") {
                val size = queryParamOrNull<Int>("size") ?: 20
                ok("size=${size + 1}")
            }
        }

        val testKit = RelixTestKit(app)
        val response = testKit.handleRequest(
            method = "GET",
            path = "/items",
            queryParameters = mapOf("size" to listOf("5")),
        )

        assertEquals(200, response.statusCode)
        assertEquals("size=6", response.body.toString(Charsets.UTF_8))
    }

    @Test
    fun `queryParam Boolean accepts true and false`() {
        val app = newApp()
        app.routing {
            get("/items") {
                val active = queryParam<Boolean>("active")
                ok("active=$active")
            }
        }

        val testKit = RelixTestKit(app)

        val enabled = testKit.handleRequest(
            method = "GET",
            path = "/items",
            queryParameters = mapOf("active" to listOf("true")),
        )
        assertEquals("active=true", enabled.body.toString(Charsets.UTF_8))

        val disabled = testKit.handleRequest(
            method = "GET",
            path = "/items",
            queryParameters = mapOf("active" to listOf("false")),
        )
        assertEquals("active=false", disabled.body.toString(Charsets.UTF_8))
    }

    @Test
    fun `queryParam Boolean rejects other values`() {
        val app = newApp()
        app.routing {
            get("/items") {
                val active = queryParam<Boolean>("active")
                ok("active=$active")
            }
        }

        val testKit = RelixTestKit(app)
        val response = testKit.handleRequest(
            method = "GET",
            path = "/items",
            queryParameters = mapOf("active" to listOf("1")),
        )

        assertEquals(400, response.statusCode)
    }

    @Test
    fun `unsupported target type returns 400`() {
        val app = newApp()
        app.routing {
            get("/items") {
                val query = queryParam<CreateUserRequest>("q")
                ok("query=$query")
            }
        }

        val testKit = RelixTestKit(app)
        val response = testKit.handleRequest(
            method = "GET",
            path = "/items",
            queryParameters = mapOf("q" to listOf("""{"name":"Relix","age":1}""")),
        )

        assertEquals(400, response.statusCode)
    }
}

跟上面的 ReceiveTest 一樣,每個 app 都要先 install(ErrorHandling),這兩組函式也是用 throw 表達錯誤,沒有 middleware 接住就斷言不到 status code

queryParam missing returns 400queryParam invalid value returns 400 一定要分開寫,這正是前面那行手動轉型的問題,?: 1 把「沒帶」跟「帶錯」壓成同一個結果,分成兩個測試,兩條路各自定住,之後誰把實作改成「轉不出來就回預設值」,第二個測試會立刻失敗

queryParamOrNull 也要兩個測試,只測沒帶回 null 是不夠的,queryParamOrNull converts when present 特地把回來的值 + 1 再輸出,如果實作偷懶回的是字串 "5" 而不是 Int,這裡就編譯不過,型別安全這件事要用得到型別的方式才驗得出來

Boolean 那兩個是一組,accepts true and false 確認兩個合法值都認得,rejects other values"1" 進去要拿到 400,這個測試定住的是「嚴格」這個選擇,Kotlin 的 toBoolean() 會把任何不是 "true" 的字串都當成 false?active=1 這種很常見的寫法就會被默默解讀成關閉,跟 client 想的相反,寧可回 400 叫它講清楚

最後一個 unsupported target type returns 400 送一個 @Serializable 的 data class 當目標型別,就算 query string 裡塞的是合法 JSON 也一樣回 400,CreateUserRequest 已經在 ReceiveTest.kt 宣告過,同 package 直接用,這個測試定住的是這組 API 的邊界,它只做標量轉型,不做物件反序列化,理由留到最後的設計取捨那節講

實作 queryParam<T>()

測試把行為講完了,實作補上,一樣放在 RelixCall 類別裡


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> queryParam(name: String): T {
            val raw = request.queryParameters[name]?.firstOrNull()
                ?: throw RelixHttpException(400, "Missing required query parameter: $name")
            return convertQueryParam<T>(raw, name)
        }

        inline fun <reified T : Any> queryParamOrNull(name: String): T? {
            val raw = request.queryParameters[name]?.firstOrNull() ?: return null
            return convertQueryParam<T>(raw, name)
        }

        inline fun <reified T : Any> convertQueryParam(raw: String, name: String): T {
            val result = when (T::class) {
                String::class -> raw
                Int::class -> raw.toIntOrNull()
                Long::class -> raw.toLongOrNull()
                Boolean::class -> raw.toBooleanStrictOrNull()
                Double::class -> raw.toDoubleOrNull()
                else -> throw RelixHttpException(400, "Unsupported query param type: ${T::class.simpleName}")
            }

            @Suppress("UNCHECKED_CAST")
            return result as? T
                ?: throw RelixHttpException(400, "Invalid value for '$name': '$raw'")
        }

        // ...
    }

兩個公開的函式只差在參數不存在的時候要做什麼,queryParam() 丟 400,queryParamOrNull() 回 null,轉型的部份完全一樣,所以抽成 convertQueryParam() 共用

convertQueryParam()when 根據目標型別分派,toIntOrNull() 這一系列的共同點是轉不出來就回 null,不丟 exception,所以下面用一個 as? T ?: throw 就把所有型別的失敗集中在同一處,錯誤訊息帶上參數名稱跟原始值,client 才知道是哪一個參數送錯

toBooleanStrictOrNull() 對應的是 rejects other values 那個測試,只認 "true""false"else 分支對應的是 unsupported target type 那個測試,遇到不認得的型別直接 400,而不是回 null 或丟 500,因為這種情況多半是開發者寫錯型別參數,訊息裡把型別名稱印出來比較好找

TDD 先確認 created() 的行為

receive<T>() 讀進來之後,POST /users 要回的是 201,不是 200,所以還缺一個走 content negotiation 的 created(value)

它跟第 20 篇的 ok(value) 是同一個模式,需要 Accept header、需要 registry,所以一樣是 RelixCall 的成員,測試也一樣要走完整條路徑,測試檔案放 CreatedTest.kt

要回的東西直接用第 20 篇宣告在 RelixSerializationConverterTest.ktTestUser,同 package 拿得到,不用再宣告一次,也不要拿 CreateUserRequest 來充數,那是 request 的形狀,created() 回的是建好的資源

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class CreatedTest {

    private fun createApp(): RelixApplication {
        val app = RelixApplication()
        app.install(ContentNegotiation) { json() }
        app.routing {
            post("/users") {
                created(TestUser("Relix", 1))
            }
            post("/ping") {
                created("pong")
            }
        }
        return app
    }

    @Test
    fun `created value returns 201 with json body`() {
        val testKit = RelixTestKit(createApp())

        val response = testKit.handleRequest(
            method = "POST",
            path = "/users",
            headers = mapOf("Accept" to listOf("application/json")),
        )

        assertEquals(201, response.statusCode)
        assertTrue(
            response.headers["Content-Type"]
                ?.first()?.contains("application/json") == true,
        )
        assertEquals(
            """{"name":"Relix","age":1}""",
            response.body.toString(Charsets.UTF_8),
        )
    }

    @Test
    fun `created string still returns text plain`() {
        val testKit = RelixTestKit(createApp())

        val response = testKit.handleRequest(method = "POST", path = "/ping")

        assertEquals(201, response.statusCode)
        assertTrue(
            response.headers["Content-Type"]
                ?.first()?.contains("text/plain") == true,
        )
        assertEquals("pong", response.body.toString(Charsets.UTF_8))
    }

    @Test
    fun `created value with unsupported Accept returns 406`() {
        val testKit = RelixTestKit(createApp())

        val response = testKit.handleRequest(
            method = "POST",
            path = "/users",
            headers = mapOf("Accept" to listOf("text/xml")),
        )

        assertEquals(406, response.statusCode)
    }

    @Test
    fun `created string works without ContentNegotiation`() {
        val app = RelixApplication()
        app.routing {
            post("/ping") { created("pong") }
        }

        val testKit = RelixTestKit(app)
        val response = testKit.handleRequest(method = "POST", path = "/ping")

        assertEquals(201, response.statusCode)
        assertEquals("pong", response.body.toString(Charsets.UTF_8))
    }
}

前三個測試跟第 20 篇的 ok(value) 幾乎是對照組,回 201、Accept 不支援回 406、字串版本維持 text/plain

真正的重點是第四個 created string works without ContentNegotiation,它刻意建立一個沒有安裝 ContentNegotiation 的 app,這是前面幾篇大量存在的情況,第 07、10、11 篇那些路由測試都只是要一個 201,沒有人裝 plugin,這個測試要是失敗,代表前面的測試全都會跟著有問題

實作 created()

created()ok() 同理,只是 status code 是 201,實作方式一模一樣,一樣補進 RelixCall.kt 裡面


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> created(value: T): RelixResponse {
            val accept = request.header("Accept")
            val converter = application.converterRegistry.select(accept)
                ?: return RelixResponse(406, mapOf(), "Not Acceptable".toByteArray())
            val body = converter.encode(value, typeOf<T>())
            return RelixResponse(
                201,
                mapOf("Content-Type" to listOf("${converter.contentType}; charset=utf-8")),
                body,
            )
        }

        // 跟第 20 篇的 ok() 一樣,字串版本要保留一個明確的多載
        fun created(text: String): RelixResponse = RelixResponse(
            201,
            mapOf("Content-Type" to listOf("text/plain; charset=utf-8")),
            text.toByteArray(Charsets.UTF_8),
        )
    }

    // ...

這個字串多載一定要補,而上面第四個測試就是為了它寫的

第 05 篇的 created() 是 top-level function,但只要 RelixCall 上出現同名成員,member 的優先順序就高於 top-level,前面第 07、10、11 篇那些 created("user created") 會全部被泛型版本吃掉,T 推導成 String,然後去問一個沒有註冊任何 converter 的 registry,結果就是 406,第 20 篇的 ok() 就是為了同一個理由保留字串版本,這裡是第二次遇到,只要之後再加 accepted()noContent(value) 這類方法,同樣的多載都要補一份

在 main 裡組起來跑一次

三個 API 都有測試了,最後裝上 plugin 實際跑一次,POST /usersreceive<T>() 讀 body 再用 created() 回 201,GET /usersqueryParam<T>() 讀查詢條件

import kotlinx.serialization.Serializable

@Serializable
data class CreateUserRequest(val name: String, val age: Int)

@Serializable
data class UserResponse(val id: Long, val name: String, val age: Int)

@Serializable
data class PagedResponse(val items: List<UserResponse>, val page: Int, val size: Int)

private val users = listOf(
    UserResponse(1, "Relix", 1),
    UserResponse(2, "Ktor", 9),
)

fun main() {
    val app = RelixApplication()

    app.install(Logging) { logger = ConsoleLogger() }
    app.install(ErrorHandling)
    app.install(ContentNegotiation) { json() }

    app.routing {
        post("/users") {
            val req = receive<CreateUserRequest>()
            created(UserResponse(3, req.name, req.age))
        }

        get("/users") {
            val keyword = queryParam<String>("keyword")
            val page = queryParamOrNull<Int>("page") ?: 1
            val size = queryParamOrNull<Int>("size") ?: 20
            val items = users.filter { it.name.contains(keyword, ignoreCase = true) }
            ok(PagedResponse(items, page, size))
        }
    }

    JdkHttpServerAdapter(app).start(8080)
}

ErrorHandling 這次一定要裝,receive<T>()queryParam<T>() 都是用 throw 表達錯誤,沒有它的話 exception 會穿出去變成 500

CreateUserRequest 前面是宣告在 ReceiveTest.kt 裡的,那是為了讓測試自己帶著 fixture,真的要跑起來的時候它屬於正式程式碼,搬到 src/ 就好,測試那邊的宣告跟著刪掉,同 package 兩邊各留一份雖然編譯得過 (test 的宣告會遮蔽 main 的),但兩份定義不同步的時候會很難查

建立一個 user

curl -i -X POST -H "Content-Type: application/json" \
  -d '{"name":"Cash","age":18}' localhost:8080/users
HTTP/1.1 201 Created
Content-type: application/json; charset=utf-8
Content-length: 31

{"id":3,"name":"Cash","age":18}

201 而不是 200,body 是 created() 序列化出來的 JSON,整個 handler 只有兩行,沒碰任何 JSON API

送壞掉的 JSON

curl -i -X POST -H "Content-Type: application/json" \
  -d 'not valid json' localhost:8080/users
HTTP/1.1 400 Bad Request
Content-type: text/plain; charset=utf-8
Content-length: 139

Bad Request: Unexpected JSON token at offset 0: Expected start of the object '{', but had 'n' instead at path: $
JSON input: not valid json

kotlinx.serialization 的錯誤訊息連 offset、實際讀到的字元、甚至原始的 JSON input 都印出來了,這是 receive<T>()e.message 帶進 400 的好處,測試裡只斷言 status code,實際打一次才看得到 client 拿到什麼

回應是 text/plain 而不是 JSON,因為錯誤 response 是第 16 篇的 errorResponse() 產生的,它不走 content negotiation,就是一段純文字,這也表示錯誤訊息會原封不動送到 client 手上,JSON input: 那行會把使用者送的原始內容回顯出去,正式環境要留意這件事,第 27 篇的 StatusPages 會把錯誤回應收斂成統一的結構

少一個欄位

curl -i -X POST -H "Content-Type: application/json" \
  -d '{"name":"Cash"}' localhost:8080/users
HTTP/1.1 400 Bad Request
Content-type: text/plain; charset=utf-8
Content-length: 113

Bad Request: Field 'age' is required for type with serial name 'CreateUserRequest', but it was missing at path: $

這就是前面說的 MissingFieldException,它是 SerializationException 的子類別,所以跟壞掉的 JSON 走同一個 catch,但訊息精確到是哪一個欄位沒帶

完全不送 Content-Type

curl -i -X POST -H "Content-Type:" \
  -d '{"name":"Cash","age":18}' localhost:8080/users
HTTP/1.1 415 Unsupported Media Type
Content-type: text/plain; charset=utf-8
Content-length: 28

Unsupported Media Type: null

-H "Content-Type:" 冒號後面留空,curl 就不會送這個 header,跟第 20 篇拿掉 Accept 是同一招,selectByContentType(null) 回 null,receive<T>() 丟 415,這條路在測試裡驗過,但要用 curl 才做得出「完全不送」這個情況,因為 curl 帶 body 的時候預設會自己補上 application/x-www-form-urlencoded

查詢,帶上必填參數

curl -i "localhost:8080/users?keyword=relix&page=2"
HTTP/1.1 200 OK
Content-type: application/json; charset=utf-8
Content-length: 62

{"items":[{"id":1,"name":"Relix","age":1}],"page":2,"size":20}

keyword=relix 有比對到 Relix (ignoreCase = true),page=2 被轉成 Int 放進 response,size 沒帶所以走 ?: 20 的預設值

漏掉必填參數

curl -i "localhost:8080/users"
HTTP/1.1 400 Bad Request
Content-type: text/plain; charset=utf-8
Content-length: 41

Missing required query parameter: keyword

參數型別不對

curl -i "localhost:8080/users?keyword=relix&page=abc"
HTTP/1.1 400 Bad Request
Content-type: text/plain; charset=utf-8
Content-length: 31

Invalid value for 'page': 'abc'

這兩個 400 的訊息不一樣,一個講「少了什麼」、一個講「哪個值不對」,這正是前面把 missing 跟 invalid 分成兩個測試的理由,如果當初用 ?: 1 這種寫法,這兩趟都會安靜地變成 page=1,client 完全不知道自己打錯字

log 這邊七筆都記得到

[Relix] POST /users -> 201 (11ms)
[Relix] POST /users -> 400 (2ms)
[Relix] POST /users -> 400 (1ms)
[Relix] POST /users -> 415 (0ms)
[Relix] GET /users -> 200 (8ms)
[Relix] GET /users -> 400 (0ms)
[Relix] GET /users -> 400 (0ms)

第一筆 11ms、後面掉到 0 到 2ms,跟第 20 篇的數字幾乎一樣,原因那邊講過了,第一次走到序列化要把 serializer 找出來、把相關的類別載進來

這次多看到一件第 20 篇看不到的事,GET 那筆的 8ms,第 20 篇從頭到尾只有 User 一個型別,第一趟付完就一路用下去,這篇有三個,POST 那幾趟查過的是 CreateUserRequestUserResponseGET 要回的 PagedResponse 之前沒出現過,裡面還包了一個 List<UserResponse>,巢狀的 serializer 也得一起查出來,所以這筆成本又付了一次,它是每個型別各付一次,不是整個 app 付一次

常見陷阱與設計取捨

receive<T>() 為什麼用 try/catch 包而不是讓 exception 穿透 ?

如果讓 SerializationException 穿透到 error handling middleware,它會被當成 500 Internal Server Error,但「client 送的 JSON 壞掉」不是 server 的問題,receive<T>() 自己 catch 並轉成 400,語意更精確,第 27 篇的 StatusPages 也可以做類似的事,但 receive 層先攔截更直覺

JSON 少一個欄位就 400,那選填欄位怎麼辦 ?

receive with missing field returns 400 驗證的是「欄位缺了就失敗」,但實務上一定會有選填欄位,這件事不在框架這邊解決,在 data class 的宣告

基本上就給預設值就好

@Serializable
data class CreateUserRequest(
    val name: String,
    val age: Int = 18,            // 沒帶就用 18
    val nickname: String? = null, // 沒帶就是 null
)

{"name":"Cash"} 這樣送就不會 400,kotlinx.serialization 只對「沒有預設值、又沒出現在 JSON 裡」的欄位丟 MissingFieldException

@Transient 不是這題的答案,它標記的是「這個欄位完全不參與序列化」,被標記的欄位一定要有預設值,序列化的時候不會輸出,反序列化的時候要是 JSON 真的送了那個 key,反而會因為 unknown key 而失敗,它處理的是「這個欄位不該出現在 API 上」,不是「這個欄位可以不帶」

反方向的坑更容易遇到,client 多送一個 data class 沒有的欄位,預設設定會直接失敗

{"name":"Cash","age":18,"extra":true}
→ Encountered an unknown key 'extra'

前端多帶一個欄位、或是 API 版本沒同步,就會變成 400,要讓它安靜忽略得自己開

app.install(ContentNegotiation) {
    json(Json { ignoreUnknownKeys = true })
}

第 20 篇的 json() 收一個 Json 實體就是為了這個,框架不預設幫你決定要嚴格還是寬鬆,這是 API 的取捨,嚴格的好處是 client 欄位名打錯會馬上知道,寬鬆的好處是欄位增減不會弄壞舊的呼叫端

queryParam 為什麼不用 converter ?

Query parameter 永遠是字串,不涉及 Content-Type 協商,用一個簡單的 when + toIntOrNull() 就夠了,把它硬塞進 RelixConverter 反而會讓介面變得不乾淨 (converter 是為 body 序列化設計的,不是為 query string 設計的)

可以把整個 query string 反序列化成一個物件嗎 ?

queryParam<T>() 的簽名要一個 name,語意就是「取這一個 key」,unsupported target type returns 400 那個測試也把這條界線定住了,傳一個 data class 進去會走到 whenelse 分支回 400,這組 API 只做標量轉型

Ktor 核心也是同一個做法,call.request.queryParameters 拿到的是 Parameters,本質上就是 StringValues,只能 by key 取值,call.receive<T>() 只吃 request body,不會去碰 query string,所以這裡主要跟 Ktor 是對齊的

Ktor 真的要「整包 query string 變成物件」的時候,走的是另一條路,Resources plugin

@Resource("/articles")
class Articles(val sort: String? = "new", val page: Int = 1)

install(Resources)
routing {
    get<Articles> { articles ->
        // articles.sort、articles.page 都已經是型別安全的了
    }
}

@Resource 本身帶有 @Serializable 的行為,由 kotlinx.serialization 負責把 path parameter 跟 query parameter 一起解進這個 class,這是 Ktor 3.x 用來取代 2.x Locations 的方案

重點是形狀完全不一樣,它綁在 routing 層,路由本身就是那個型別,而不是在 handler 裡呼叫一個函式去取值,兩者是互補的,不是誰取代誰,要在 Relix 做出這件事,得自己寫一個 kotlinx.serialization 的 decoder,把 Map<String, List<String>> 餵給 serializer<T>(),那是另一個層次的題目,跟這篇 inline + reified + when 的輕量路線不同,所以這裡先不做

queryParamOrNull 和預設值怎麼配合 ?

queryParamOrNull<Int>("page") ?: 1 是 Kotlin 的標準做法,比起在 queryParam() 加一個 defaultValue 參數更簡潔,也不用多一個 overload,Elvis operator 讓意圖一眼就看懂


小結

receive<T>() 把 body 讀取、Content-Type 比對、反序列化整合在一起,Content-Type 不支援回 415,解析失敗回 400,兩種錯誤語意清楚分開,queryParam<T>()queryParamOrNull<T>()inline + reified + when 做型別安全的 query parameter 讀取,handler 從此不需要手動解析 JSON 或手動轉型,輸入輸出都交給框架處理

created() 補上 201 這個缺口,跟第 20 篇的 ok() 一樣要保留字串多載,否則前面幾篇那些沒安裝 ContentNegotiation 的 created("...") 會整批變成 406

測試一樣分兩層,ConverterRegistryTest 只管 selectByContentType() 的比對規則,不用起 server,ReceiveTestQueryParamTestCreatedTest 才走完整條路徑,從 header 進去、status code 出來


下一篇

下一篇會做 Request Validation,用 DSL 定義驗證規則,輸入不合法回 422 Unprocessable Entity


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin 手刻 Ktor 從零開始 Day 20 Content Negotiation,統一的內容協商與 Response 序列化
下一篇
Kotlin 手刻 Ktor 從零開始 Day 22 Request Validation,輸入驗證機制
系列文
Kotlin 手刻 Ktor 從零開始26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言