
第 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 的部份給收尾
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,那邊的 FakeConverter 跟 registry() 直接拿來用
@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/json,trim() 處理分號前後可能留下的空白,*/* 不需要特別寫程式碼擋,它本來就不會出現在 converters 裡,firstOrNull 自然就回 null
receive<T>() 的行為底層備齊了,接下來是把三個元件串起來的 receive<T>()
body: ByteArray + body caching)selectByContentType())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"} 則是少了 age,kotlinx.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 + reified 讓 typeOf<T>() 在呼叫端展開,跟第 20 篇的 ok() 是同一個機制,compiler 會把泛型型別資訊保留下來,converter 才能拿到完整的 KType
「reified」可以理解成具體化,一般的泛型函式不能把非 reified 的 T 傳給 typeOf<T>(),標記 reified 之後,compiler 能在 inline 的呼叫點保留實際型別,因此可以取得 User 或 List<Int> 對應的完整 KType,reified 函式通常只負責取得型別,再把工作交給一般函式,避免每個呼叫點展開過多 bytecode
錯誤處理分兩層,剛好對應測試的第二組跟第三組
try/catch 只攔 SerializationException,這一個型別就把三個 400 的測試都涵蓋了,而 JSON 格式壞掉丟的是 JsonDecodingException,欄位缺了丟的是 MissingFieldException,兩個都是 SerializationException 的子類別,型別對不上也走同一條,攔父類別就不用一個一個列
其他程式錯誤不攔,繼續往外丟,交給 error handling middleware 回 500,handler 裡自己寫的 bug 不應該被包裝成 400 送給 client
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 400 跟 queryParam 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,因為這種情況多半是開發者寫錯型別參數,訊息裡把型別名稱印出來比較好找
created() 的行為receive<T>() 讀進來之後,POST /users 要回的是 201,不是 200,所以還缺一個走 content negotiation 的 created(value)
它跟第 20 篇的 ok(value) 是同一個模式,需要 Accept header、需要 registry,所以一樣是 RelixCall 的成員,測試也一樣要走完整條路徑,測試檔案放 CreatedTest.kt
要回的東西直接用第 20 篇宣告在 RelixSerializationConverterTest.kt 的 TestUser,同 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) 這類方法,同樣的多載都要補一份
三個 API 都有測試了,最後裝上 plugin 實際跑一次,POST /users 用 receive<T>() 讀 body 再用 created() 回 201,GET /users 用 queryParam<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 那幾趟查過的是 CreateUserRequest 跟 UserResponse,GET 要回的 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 進去會走到 when 的 else 分支回 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,ReceiveTest、QueryParamTest 跟 CreatedTest 才走完整條路徑,從 header 進去、status code 出來
下一篇會做 Request Validation,用 DSL 定義驗證規則,輸入不合法回 422 Unprocessable Entity
同步刊登於 Blog
圖片來源:AI 產生