
day 13 結尾那 2 個一模一樣的 400 訊息的問題,這篇只解決得掉一半,RequestValidation 補的是「JSON 解得開,但值不合理」那一段,title 是空字串、長度不對都歸它管,至於沒有給 title 的問題,它連看都看不到,因為請求還沒到它面前就結束了
規則寫在哪、它掛在 receive pipeline 的哪一個 phase、驗證失敗之後 client 拿到什麼、伺服器 log 又留下什麼,這篇一項一項實測,最後幾件對不上的地方要翻原始碼才問得出答案
CreateTodoRequest 的 title 加上不能空白、長度不能超過 100 2 條規則TodoRoutes.kt 裡那 2 個手寫檢查該不該搬進來先加相依套件,build.gradle.kts 的 dependencies 區塊裡接在 contentNegotiation 後面
implementation(ktorLibs.server.requestValidation)
規則本身另外開一個檔案 src/main/kotlin/com/cashwu/todo/TodoValidation.kt,理由跟 day 07 把路由拆成 Route.todoRoutes() 一樣,Application.kt 是組裝的地方,不是規則住的地方
import io.ktor.server.plugins.requestvalidation.RequestValidationConfig
import io.ktor.server.plugins.requestvalidation.ValidationResult
const val TITLE_MAX_LENGTH = 100
fun RequestValidationConfig.todoValidation() {
validate<CreateTodoRequest> { request ->
val reasons = mutableListOf<String>()
if (request.title.isBlank()) {
reasons.add("title 不能是空白")
}
if (request.title.length > TITLE_MAX_LENGTH) {
reasons.add("title 長度不能超過 $TITLE_MAX_LENGTH 個字")
}
if (reasons.isEmpty()) ValidationResult.Valid else ValidationResult.Invalid(reasons)
}
}
isBlank() 擋的是空字串跟純空白,這 2 種在 kotlinx.serialization 眼裡都是完全合法的 String,序列化那一層沒有理由攔,但一筆標題是空的待辦事項存進去,列表上就是一列空白,沒有人知道那是什麼
長度上限 100 是我憑「一句待辦事項寫得完」抓的,這種數字沒有客觀正解,現在用的 String.length 算的是 UTF-16 code unit,不完全等於使用者看見的字數,碰到 emoji 時可能更早超過 100,先開成 const val 是為了上限只有一個地方要改
最後一行是這條規則的判斷結果,型別是 ValidationResult,這是 plugin 自己的型別,只有 Valid 跟 Invalid 2 種,這裡沒有寫成檢查到就 return,是為了讓 2 條規則都跑完,違反的原因先累積在 reasons 裡,最後一次決定要回哪一個,list 是空的就回 Valid,有東西就把整串包進 Invalid,差別在同時違反 2 條的時候,提早 return 只會講第 1 條,這樣寫 2 條都會出現在訊息裡,後面實測 101 個空白就是在看這件事
寫成 RequestValidationConfig 的 extension function,是為了讓它能直接塞進 install 的 lambda 裡,Application.kt 那邊就只有 2 行
install(RequestValidation) {
todoValidation()
}
位置接在 install(ContentNegotiation) 後面,import 補一個 io.ktor.server.plugins.requestvalidation.RequestValidation,這 2 個 plugin 掛在不同 phase,install 的先後不影響執行順序,下一節會講為什麼,這裡照著閱讀順序排
day 12 追 ContentNegotiation 的時候看過 hook 跟 phase 的對應
RequestValidation.kt 主體很短
public val RequestValidation: RouteScopedPlugin<RequestValidationConfig> = createRouteScopedPlugin(
"RequestValidation",
::RequestValidationConfig
) {
val validators = pluginConfig.validators
on(RequestBodyTransformed) { content ->
val failures = validators.filter { it.filter(content) }
.map { it.validate(content) }
.filterIsInstance<ValidationResult.Invalid>()
if (failures.isNotEmpty()) {
throw RequestValidationException(content, failures.flatMap { it.reasons })
}
}
// ...
}
RequestBodyTransformed 是這個 plugin 自己定義的 hook,同一個檔案的最下面
private object RequestBodyTransformed : Hook<suspend (content: Any) -> Unit> {
override fun install(
pipeline: ApplicationCallPipeline,
handler: suspend (content: Any) -> Unit
) {
pipeline.receivePipeline.intercept(ApplicationReceivePipeline.After) {
handler(subject)
}
}
}
receivePipeline 的 After phase,對照 day 12 查到的,ContentNegotiation 的 convertRequestBody() 是一個 onCallReceive,這個 hook day 10 介紹過,day 12 查 PluginBuilder.kt 對出它掛的是同一條 pipeline 的 Transform phase,ApplicationReceivePipeline 的 phase 順序是 Before、Transform、After,所以整條路走起來是這樣
Before,body 還是一條 ByteReadChannel
Transform,ContentNegotiation 把 channel 解成 CreateTodoRequest
After,RequestValidation 拿到已經是物件的 subject,跑規則驗證發生在反序列化之後,這是整篇文章的基礎,content 進到 validator 的時候已經是型別正確的 Kotlin 物件,所以規則裡可以直接寫 request.title.isBlank(),不用碰任何 JSON 的東西,反過來說也成立,只要反序列化那一步失敗,pipeline 在 Transform 就丟了 exception,After 這一站永遠不會執行
因為順序是 phase 決定的,install 的先後就沒有影響。把 install(RequestValidation) 寫在 install(ContentNegotiation) 前面,驗證一樣照跑
還有 3 個從原始碼看出來的細節
第 1,filter 決定哪些 body 要被這個 validator 看,我們用的 validate<T> { } 是 reified 的版本,它的 filter 實作是 value is T,所以只有 receive<CreateTodoRequest>() 解出來的物件會進到規則裡,別的路由收別的型別完全不受影響。如果哪天寫成 validate<Any> { },那就是全站每一個 receive() 都要過這一關
第 2,它是 createRouteScopedPlugin,跟 ContentNegotiation 一樣可以只裝在某段路由上,同一個 CreateTodoRequest 在 POST 跟 PUT 想要不同的規則時,這是 Ktor 給的答案,把 plugin 分別裝在 2 個 route 區塊裡,各自帶各自的規則
第 3,config 裡還有一個 validateContentLength() 預設關閉,開了之後 plugin 會再多掛一個 ReceiveRequestBytes hook,把實際讀到的 byte 數跟 Content-Length header 比對,對不上就丟 IOException,注意是 kotlinx.io 那個不是 java.io,它跟欄位驗證是 2 件不相干的事,只是剛好住在同一個 plugin 裡
規則寫好了,先寫測試把行為固定下來,再開伺服器看回應長什麼樣
開一個 src/test/kotlin/com/cashwu/todo/RequestValidationTest.kt,8 個測試分成 3 組
先處理一件跟 day 12 一樣的事,todos 是整個 JVM 共用的全域變數,這個 class 裡有 2 個測試會真的建立一筆 todo(等一下看到的 title at the max length is accepted 和 unknown field is dropped before validation runs),不重設的話,依測試執行的順序,後面斷言 todos.size 的測試就會對不上,所以 RequestValidationTest.kt 開頭一樣放一個 @BeforeTest
package com.cashwu.todo
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.requestvalidation.RequestValidation
import io.ktor.server.plugins.requestvalidation.RequestValidationException
import io.ktor.server.request.receive
import io.ktor.server.response.respondText
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class RequestValidationTest {
@BeforeTest
fun resetTodos() {
todos.clear()
todos.addAll(defaultTodos)
}
// 底下三組測試都放進這個 class
}
有了它,每個測試開跑前 todos 都固定回 3 筆 defaultTodos,接下來的斷言才站得住腳
第 1 組驗證規則本身,3 個測試。blank title is rejected 送純空白
// 這三個斷言 500 的期望值在 day 15 裝上 StatusPages 之後
// 會全部改成 400,不是寫錯
@Test
fun `blank title is rejected`() = testApplication {
application {
module()
}
val response = client.post("/todos") {
header(HttpHeaders.ContentType, ContentType.Application.Json)
setBody("""{"title":" "}""")
}
assertEquals(HttpStatusCode.InternalServerError, response.status)
assertEquals(3, todos.size)
}
斷言 500 不是筆誤,驗證失敗照理該是 400,實際跑出來卻是 500,這個測試就照著跑出來的結果寫,理由下一節從原始碼追,那 2 行註解是留給下次翻到的自己看的,這一組有 3 個測試的期望值都要在 day 15 一起改
assertEquals(3, todos.size) 那行不是多餘的,驗證跑在 After phase,handler 還沒被執行,所以被擋下來的請求不能留下任何痕跡,todos 要維持在 defaultTodos 的 3 筆,少了這行,就算哪天規則寫壞成「先建立再驗證」,測試也還是通過
title one char over the max length is rejected 送 101 個 a,剛好越界一格
@Test
fun `title one char over the max length is rejected`() = testApplication {
application {
module()
}
val response = client.post("/todos") {
header(HttpHeaders.ContentType, ContentType.Application.Json)
setBody("""{"title":"${"a".repeat(TITLE_MAX_LENGTH + 1)}"}""")
}
assertEquals(HttpStatusCode.InternalServerError, response.status)
assertEquals(3, todos.size)
}
title at the max length is accepted 送剛好 100 個,斷言 201,todos 多一筆
@Test
fun `title at the max length is accepted`() = testApplication {
application {
module()
}
val response = client.post("/todos") {
header(HttpHeaders.ContentType, ContentType.Application.Json)
setBody("""{"title":"${"a".repeat(TITLE_MAX_LENGTH)}"}""")
}
assertEquals(HttpStatusCode.Created, response.status)
assertEquals(4, todos.size)
}
這個測試固定的是目前 String.length 的邊界,不代表所有看起來像一個字的 Unicode 內容都只算一格,邊界值該過就要過,差一個 ASCII 字元才開始錯,這跟上面那個 101 的測試是一組
第 2 組是上一節那幾條界線,4 個測試,前 3 個都走 module(),差別只在 body 送什麼
malformed json never reaches validation 送 not json
@Test
fun `malformed json never reaches validation`() = testApplication {
application {
module()
}
val response = client.post("/todos") {
header(HttpHeaders.ContentType, ContentType.Application.Json)
setBody("not json")
}
assertEquals(HttpStatusCode.BadRequest, response.status)
assertEquals(3, todos.size)
}
斷言 400,這是 Transform 那一站丟出來的 BadRequestException,證明反序列化失敗就到不了 After,validator 根本沒被呼叫
missing title is still a serialization error 送 {"done":true}
@Test
fun `missing title is still a serialization error`() = testApplication {
application {
module()
}
val response = client.post("/todos") {
header(HttpHeaders.ContentType, ContentType.Application.Json)
setBody("""{"done":true}""")
}
assertEquals(HttpStatusCode.BadRequest, response.status)
assertEquals(3, todos.size)
}
一樣是 400,把「RequestValidation 管不到必填欄位」這件事確認下來,day 13 那個伏筆補不完的那一半就是它
unknown field is dropped before validation runs 送多帶一個欄位的 body
@Test
fun `unknown field is dropped before validation runs`() = testApplication {
application {
module()
}
val response = client.post("/todos") {
header(HttpHeaders.ContentType, ContentType.Application.Json)
setBody("""{"title":"倒垃圾","priority":1}""")
}
assertEquals(HttpStatusCode.Created, response.status)
assertEquals(4, todos.size)
}
斷言 201,ignoreUnknownKeys 先把 priority 丟掉,validator 拿到的是乾淨的 CreateTodoRequest,多帶的欄位不會變成驗證的問題
第 4 個不走 module(),自己組一個 application,把 install(RequestValidation) 寫在 install(ContentNegotiation) 前面
@Test
fun `install order does not change when validation runs`() = testApplication {
application {
install(RequestValidation) {
todoValidation()
}
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
routing {
post("/todos") {
call.receive<CreateTodoRequest>()
call.respondText("created")
}
}
}
val response = client.post("/todos") {
header(HttpHeaders.ContentType, ContentType.Application.Json)
setBody("""{"title":" "}""")
}
assertEquals(HttpStatusCode.InternalServerError, response.status)
}
驗證照樣生效,斷言 500,這就是前面說順序由 phase 決定、不由 install 先後決定的那件事
第 3 組只有一個測試,用來看 reasons 這個 list 的實際內容,前面的測試都只斷言到狀態碼,看不見裡面收集了幾個原因、順序是什麼
@Test
fun `receive throws with every reason collected`() = testApplication {
var reasons: List<String> = emptyList()
application {
install(ContentNegotiation) { json() }
install(RequestValidation) { todoValidation() }
routing {
post("/probe") {
try {
call.receive<CreateTodoRequest>()
call.respondText("valid")
} catch (cause: RequestValidationException) {
reasons = cause.reasons
call.respondText("invalid")
}
}
}
}
val response = client.post("/probe") {
header(HttpHeaders.ContentType, ContentType.Application.Json)
setBody("""{"title":"${" ".repeat(TITLE_MAX_LENGTH + 1)}"}""")
}
assertEquals("invalid", response.bodyAsText())
assertEquals(
listOf("title 不能是空白", "title 長度不能超過 100 個字"),
reasons,
)
}
這個測試自己搭了一條 /probe 路由,在 handler 裡把 exception 接下來,2 個原因都在,順序跟規則寫下去的順序一致,它順便示範了一件實際的事,RequestValidationException 是從 call.receive() 那一行丟出來的,plugin 掛的是 receive pipeline,而 receive() 就是啟動那條 pipeline 的呼叫,所以在 handler 裡包一個 try-catch 是接得到的,真的這樣寫當然沒必要,每個 handler 都包一次跟 day 13 之前把驗證寫在 handler 裡沒有兩樣
測試都通過了,但那個 500 還沒解釋
開伺服器看一次完整的回應,./gradlew run,先送一個正常的
curl -i -X POST -H "Content-Type: application/json" \
-d '{"title":"倒垃圾"}' localhost:8080/todos
HTTP/1.1 201 Created
X-Response-Time: 11ms
Content-Length: 84
Content-Type: application/json
{"id":4,"title":"倒垃圾","done":false,"created_at":"2026-08-30T22:47:24.799382Z"}
看起來沒問題,換成純空白的 title
curl -i -X POST -H "Content-Type: application/json" \
-d '{"title":" "}' localhost:8080/todos
HTTP/1.1 500 Internal Server Error
X-Response-Time: 7ms
Content-Length: 94
Content-Type: text/plain; charset=UTF-8
Validation failed for CreateTodoRequest(title= , done=false). Reasons: title 不能是空白
出現 500 了,規則確實擋下來了,todo 沒有被建立,但回給 client 的狀態碼是「伺服器自己壞了」,而這明明是 client 送錯東西,答案在 exception 的宣告
public class RequestValidationException(
public val value: Any,
public val reasons: List<String>
) : IllegalArgumentException("Validation failed for $value. Reasons: ${reasons.joinToString(".")}")
它繼承的是 IllegalArgumentException,在 day 12 追 400 跟 415 是誰回的時候提過,DefaultEnginePipeline.kt 裡有一張 defaultExceptionStatusCode 對照表,當時只用到 BadRequestException 和 CannotTransformContentToTypeException 2 個分支,整張沒有列出來,這裡補上
public fun defaultExceptionStatusCode(cause: Throwable): HttpStatusCode? = when (cause) {
is BadRequestException -> HttpStatusCode.BadRequest
is NotFoundException -> HttpStatusCode.NotFound
is UnsupportedMediaTypeException,
is CannotTransformContentToTypeException -> HttpStatusCode.UnsupportedMediaType
is PayloadTooLargeException -> HttpStatusCode.PayloadTooLarge
is TimeoutException, is TimeoutCancellationException -> HttpStatusCode.GatewayTimeout
else -> null
}
RequestValidationException 一個分支都對不上,落到 else -> null,而 handleFailure 那邊寫的是 defaultExceptionStatusCode(error) ?: HttpStatusCode.InternalServerError,所以就是 500,這不是 bug,是 plugin 刻意不管回應這件事,官方 KDoc 的範例裡 install(RequestValidation) 底下就直接接著一個 install(StatusPages),2 個是配套的
同一份 DefaultEnginePipeline.kt 裡的 logFailure 也有一張類似的名單
when (cause) {
is CancellationException,
is ClosedChannelException,
is ChannelIOException,
is IOException,
is BadRequestException,
is NotFoundException,
is PayloadTooLargeException,
is UnsupportedMediaTypeException,
is CannotTransformContentToTypeException -> log.debug(infoString, cause)
else -> log.error("$status: $logString", cause)
}
名單上的 exception 用 debug 記,其他全部走 error,RequestValidationException 又不在名單上,於是每一次驗證失敗,伺服器 log 都會吐一整份 ERROR 加 stack trace
ERROR io.ktor.server.Application -- Unhandled: POST - /todos
io.ktor.server.plugins.requestvalidation.RequestValidationException: Validation failed for CreateTodoRequest(title= , done=false). Reasons: title 不能是空白
時間戳跟執行緒名稱省掉了,後面還接著一整份 stack trace
day 13 那個少帶 title 的 400 就完全不一樣,它丟的是 BadRequestException,在名單上,走的是 log.debug,stack trace 一樣會印,差別在 level,root 調到 INFO 那筆就整個消失,而 RequestValidationException 走的是 log.error,調不掉
這個差別在正式環境會放大,如果有 client 一直送不合規的資料進來,你的錯誤 log 會被灌爆,而真正的 500 反而埋在裡面找不到
錯誤訊息本身也有 2 個問題,第 1 個看 2 條規則同時違反的情況,送 101 個空白字元
curl -i -X POST -H "Content-Type: application/json" \
-d "{\"title\":\"$(python3 -c 'print(" "*101)')\"}" localhost:8080/todos
HTTP/1.1 500 Internal Server Error
X-Response-Time: 0ms
Content-Length: 228
Content-Type: text/plain; charset=UTF-8
Validation failed for CreateTodoRequest(title=[101 個空白], done=false). Reasons: title 不能是空白.title 長度不能超過 100 個字
那 101 個空白在實際輸出裡是原封不動印出來的,這裡為了好讀換成了標註
2 個原因都收集到了,這點是好的,failures.flatMap { it.reasons } 把所有 Invalid 的原因串在一起,不會遇到第 1 個就停,但串起來的方式是 joinToString("."),中間只有一個句點,連空白都沒有,讀起來像是句子斷掉,可能這串訊息本來就不是設計給 client 看的,它是 exception 的 message,只是因為沒有人接住才漏到 HTTP 回應上
第 2 個問題比較嚴重,Validation failed for $value 這個 $value 是整個請求物件的 toString(),client 送進來的東西原封不動被吐回去了,CreateTodoRequest 只有 2 個欄位還好,換成一個帶 password 或 idNumber 的註冊請求,那就是把使用者剛送上來的機敏資料寫進 log,再回給發請求的人,這種 API 應該不能上線
day 13 結尾說補齊訊息是這篇的事,實際做完只補得了一半
送空字串 title 現在擋得住了,這是 RequestValidation 的功勞。但少帶 title 這種
curl -i -X POST -H "Content-Type: application/json" \
-d '{"done":true}' localhost:8080/todos
HTTP/1.1 400 Bad Request
X-Response-Time: 1ms
Content-Length: 73
Content-Type: text/plain; charset=UTF-8
Failed to convert request body to class com.cashwu.todo.CreateTodoRequest
跟 day 13 一樣的訊息,原因就是上一節的 phase 順序,title 沒有預設值,kotlinx.serialization 在 Transform 那一站就丟了 SerializationException,被包成 BadRequestException 往外走,After 這一站根本沒被執行過,同樣的訊息,送 not json 也還是拿到它
所以 client 面對這支 API,現在的處境是
3 種都是 client 的錯,卻分成 2 個狀態碼、3 種訊息品質,沒有一個能直接給前端用,RequestValidation 把「有沒有這一層驗證」這件事補起來了,但「錯誤怎麼呈現」它完全沒有處理,因為那本來就不是它的職責,要讓這 3 種情況回一致的格式,缺的是一個能接住 exception 並決定回應長相的地方,那是 day 15 的 StatusPages
在那之前,這支 API 對驗證失敗回 500 的行為是明知故留的,測試也照著現況寫死,day 15 會把它改成 400
TodoRoutes.kt 裡從 day 06 就留著 2 段手寫驗證,一個是 limit 要是 0 以上的整數,一個是 {id} 要是數字,既然這篇在做輸入驗證,順手一起搬過去很自然,但這件事可能不是這麼簡單
原因在上一節的原始碼裡已經寫得很清楚了,整個 plugin 只掛了一個跟驗證有關的 hook,RequestBodyTransformed,攔的是 receivePipeline,query string 跟 path parameter 走的是 call.request.queryParameters 和 call.parameters,那是解析 URL 得到的東西,從來不經過 receive pipeline,plugin 看不到它們,RequestValidation 是 body-oriented 的,名字裡的 request 指的是 request body
有一個灰色地帶,表單送上來的 application/x-www-form-urlencoded,用 call.receiveParameters() 拿的話會走 receive pipeline,這時候 validate<Parameters> { } 是攔得到的,但那是放在 body 裡的參數,跟網址上的 query string 是兩回事
所以那 2 個檢查留在 handler 裡不動,它們跟 body 的驗證本來就是不同性質的東西
{id} 的檢查會直接影響路由的判斷,/todos/abc 到底是 400 還是 404,是路由層的決定limit 的檢查跟 take(limit) 這個動作綁在一起,拆出去反而要在 2 個地方同步真的要把 query 參數的驗證統一管起來,Ktor 給的路是 day 08 那個 type-safe routing 的 @Resource,讓參數變成有型別的 data class,型別對不上就進不了 handler,那條路解決的是型別,值的範圍還是得自己寫,這裡就先承認一件事,Ktor 沒有提供一套涵蓋 body 加 query 加 path 的統一驗證機制,RequestValidation 只管 body
Relix 在 day 22 手刻過同一件事,做出來的東西比 Ktor 這個 plugin 大得多,4 個部分,Constraint<V> 是一條規則、FieldValidator 收集一個欄位的錯誤、validate<T> { } 用 property reference 組裝、unprocessableEntity() 把結果變成 422 回應
val userValidator = validate<RegisterRequest> {
field(RegisterRequest::name) { notBlank(); maxLength(50) }
field(RegisterRequest::age) { min(0); max(150) }
field(RegisterRequest::email) { matches(emailRegex) }
}
跟這篇 todoValidation() 裡那一串手寫的 if 擺在一起,差距很明顯,Ktor 的 RequestValidationConfig 沒有提供任何內建規則,沒有 notBlank()、沒有 maxLength(),你拿到的就是一個 (T) -> ValidationResult 的 lambda,裡面愛怎麼寫就怎麼寫,Relix 那 5 個 constraint 加上 DSL,Ktor 一個都沒給
錯誤的形狀也不一樣,Relix 的 ValidationError 帶 field 跟 message 2 個欄位,欄位名稱是 property.name 自動來的,改了 data class 的欄位名 compiler 會抓到,Ktor 的 reasons 是 List<String>,沒有欄位這個概念,所以只能自己把 title 這個字寫進訊息字串裡,改欄位名的時候沒有人會提醒我訊息過期了,要做結構化的錯誤回應 (前端要拿欄位名去標紅字那種),Ktor 這層給的東西不夠,得自己往上疊
有一個地方兩邊剛好選了相反的答案,Relix day 22 討論過 ValidationResult 要用扁平的 list 加 flag 還是 sealed class,當時選了扁平版,理由是 handler 裡的用法只有 if (!result.isValid) 一行,多一層拆解是噪音。Ktor 選的是 sealed class
public sealed class ValidationResult {
public data object Valid : ValidationResult()
public class Invalid(public val reasons: List<String>) : ValidationResult()
}
當時那段的結論是「重用次數越高,型別越嚴一點越值得」,Ktor 這個選擇正好印證了,它是一個要給所有人用的 plugin,型別分得開有 2 個好處,Valid 是 data object,通過的時候不用配一個空的 list 出來,而且型別上根本生不出「說自己合法、reasons 卻不是空的」那種矛盾物件
最大的差別在誰負責回應,Relix 的 unprocessableEntity() 把 ValidationResult 直接變成 422 加一份結構化 JSON,一個 helper 就到位,Ktor 這邊 plugin 只丟 exception,回應交給別人,代價就是這篇實測到的 500
2 種都說得通,Relix 那條路上手快,但驗證這一套就跟 HTTP 綁死了,day 22 特地把 unprocessableEntity() 放進獨立檔案,就是為了守住相依方向,Ktor 那條路把驗證跟回應徹底切開,換來的彈性是同一組規則可以在不同情境回不同的東西,代價是沒接好就是 500,而且從 API 簽名上看不出你少接了什麼
還有一個角度是誰決定什麼時候驗,Relix 是 handler 裡手動呼叫,好處是 POST /users 跟 PUT /users/{id} 可以用不同的 validator,Ktor 是自動觸發,同一個型別走到哪裡都是同一套規則,要分開只能靠 createRouteScopedPlugin 那個特性,把 plugin 裝在不同的 route 區塊,自動觸發省掉的是「忘記呼叫驗證」這個人為錯誤,這在團隊裡很實際,新加的 endpoint 只要收同一個型別就自動有規則
RequestValidation 掛在 receivePipeline 的 After phase,接在 ContentNegotiation 的 Transform 後面,所以 validator 拿到的一定是已經反序列化好的物件,也所以 JSON 解不開的請求根本走不到驗證,它做的事只有一件,規則不過就丟 RequestValidationException,回應是誰的事它不管
而這個 exception 繼承 IllegalArgumentException,不在 defaultExceptionStatusCode 那張表上,於是預設回 500、log 吐 ERROR 加 stack trace、訊息還把整個請求物件印給 client 看
把 plugin 拆小,每一個只負責自己那件事,這個設計我是認同的,基本上就是 SRP 那一套,不過感覺設計上還可以再好一點,驗證失敗這種明顯是 client 送錯東西的情況,預設就該是 4xx,回 500 等於每個裝了這個 plugin 的人都得自己補一段才不會說謊,寫 log 那件事也一樣,log.error 加一整份 stack trace 是寫死的,連個開關都沒有,這種東西應該要可以調整才是
todo-api 這一輪有了 title 的 2 條規則,day 13 的伏筆補了一半,少帶必填欄位仍然是序列化層那個講不清楚的 400,另外一個結論是 Ktor 沒有統一的輸入驗證機制,limit 跟 {id} 這種 query 和 path 參數,這個 plugin 碰不到,手寫的檢查繼續留在 handler 裡
現在這支 API 對 client 的錯誤有 3 種回法,2 種 400 加 1 種說謊的 500,格式還全是純文字,下一篇裝 StatusPages,用 exception<RequestValidationException> 把 500 改回 400、把 reasons 變成結構化的 JSON,順便處理 BadRequestException 那 2 個一模一樣的訊息,讓錯誤回應終於有一個統一的樣子
同步刊登於 Blog
圖片來源:AI 產生