iT邦幫忙

2021 iThome 鐵人賽

DAY 7
1
Modern Web

基於 Kotlin Ktor 建構支援模組化開發的 Web 框架系列 第 7

[Day 7] 實作 Request Data Validation 及 Global Exception Handler

昨天提到如何使用 kotlinx.serialization 處理 request/response json data,今天進一步延伸說明如何在 deserialize json 為 kotlin 物件之後做 data validation,如果驗證不合法就丟出 exception,最後回應 error json response。

我曾使用過的 spring 及 play 框架都有內建 data validation 解決方案,而且都有支援 JSR-303 annotation,所以 Ktor 沒有內建,實在是令人驚訝! 網路上也有不少人敲碗詢問。總之在官方還沒實作之前,只能靠自己補齊了...

我們先來看一下 Ktor Handing requests 官方文件的範例,再來思考如何加入資料驗證功能

// route path parameters
get("/user/{login}") {
    if (call.parameters["login"] == "admin") {
        // ...
    }
}

//  query parameters
get("/products") {
    if (call.request.queryParameters["price"] == "asc") {
        // ...
    }
}

// body
post("/customer") {
    val customer = call.receive<Customer>()
    //...
}

我們可以看到 ktor 的 route function 是屬於 DSL 風格,函式參數只有一個 trailing lambda,然後從 ApplicationCall 物件取得 request 資料。對比於 Spring Boot 習慣把 body, path, query 都放到函式參數再加上 @RequestBody, @PathVariable... annotation 的作法有所不同。

Request Data Validation

根據 Ktor 的開發慣例,開發者可以自定義 route extension function 進行擴展。我的設計想法是定義一個類別,內含 request data,然後實作 validate() 方法驗證資料。所以我先在 route function 定義了 Location, Form, Response 3個 refied type parameter,其中 Location 類別包含 path, query parameter,Form 則是代表 request body

@ContextDsl
inline fun <reified LOCATION : Location, reified REQUEST : Form<*>, reified RESPONSE : Any> Route.put(noinline body: suspend PipelineContext<Unit, ApplicationCall>.(LOCATION, REQUEST) -> Unit
): Route {
    return locationPut<LOCATION> {
        body(this, it, call.receiveAndValidateBody(it as? Location))
    }
}

@OptIn(InternalSerializationApi::class)
suspend inline fun <reified T : Form<*>> ApplicationCall.receiveAndValidateBody(location: Location? = null): T {
    val form = try {
        json.decodeFromString(T::class.serializer(), receiveUTF8Text())
    } catch (e: Throwable) {
        throw RequestException(InfraResponseCode.BAD_REQUEST_BODY, "can't deserialize from json: ${e.message}", e)
    }
    
    form.validate()
    
    if (location != null) {
        location.validate(form)
    }
    return form
}

然後我們可以這樣使用 route function,拿到的 location 及 form 物件已經被驗證過了

put<UUIDEntityIdLocation, UpdateUserForm, Unit> { location, form ->
    // 省略
}

Ktor Location Plugin 可以把 path, query parameter 轉為你定義的 data class 物件,我再進一步要求這個 data class 必須要繼承我自己的 Location 類別,然後實作 validate()

abstract class Location {

    protected open fun <L : Location> validator(): Validation<L>? = null

    open fun validate(form: Form<*>? = null) {
        val result = validator<Location>()?.validate(this)
        if (result is Invalid<Location>)
            throw RequestException(InfraResponseCode.BAD_REQUEST_PATH_OR_QUERYSTRING, result)
    }
}

@io.ktor.locations.Location("/{entityId}")
data class UUIDEntityIdLocation(override val entityId: UUID) : Location() {
    override fun validate(form: Form<*>?) {...}
}
abstract class Form<Self> {

    open fun validator(): Validation<Self>? = null

    open fun validate() {
        val validator = validator()
        val result = validator?.validate(this as Self)
        if (result is Invalid)
            throw RequestException(InfraResponseCode.BAD_REQUEST_BODY, result)
    }
}

@Serializable
data class UpdateUserForm(
    @Serializable(with = UUIDSerializer::class) val id: UUID,
    val enabled: Boolean? = null,
    val role: ClubUserRole? = null,
    val name: String? = null,
    val gender: Gender? = null,
    val birthYear: Int? = null,
    val email: String? = null,
    val mobile: String? = null,
    val lang: Lang? = null
) : EntityForm<UpdateUserForm, String, UUID>() {

    override fun getEntityId(): UUID = id

    override fun validator(): Validation<UpdateUserForm> = VALIDATOR

    companion object {
        private val VALIDATOR: Validation<UpdateUserForm> = Validation {
            UpdateUserForm::name ifPresent { maxLength(USER_NAME_LENGTH) }
            UpdateUserForm::email ifPresent { run(ValidationUtils.EMAIL_VALIDATOR) }
            UpdateUserForm::mobile ifPresent { run(ValidationUtils.TAIWAN_MOBILE_NUMBER_VALIDATOR) }
        }
    }
}

為什麼我選擇定義 abstract class 讓子類別繼承實作,然後再呼叫 validate() 進行驗證的作法呢? 如果使用 JSR-303 annotation 就可以不要求開發者必須繼承。我的考量是

  • Ktor 的開發風格之一就是 Not based on annotation,我想維持這種風格
  • 對於 Location, Form 這種沒有商業邏輯的 data class,我能接受使用繼承的作法
  • annotation 適合簡單的驗證邏輯,如果是複雜的邏輯,還是得自己寫 validator
  • 目前 JSR-303 的實作大都是使用 Hibernate Validator,感覺 dependency library 有點多,所以我最後選擇了 konform,它具有以下3個特點
    • Type-safe DSL
    • Zero dependencies
    • Multi-platform support (JVM, JS)

Global Exception Handler

當 konform 驗證之後會回傳 ValidationResult 物件,如果是 Invalid 子類別,我會丟出 RequestException,其中 message 屬性是 Invalid 物件的 toString,裡面會包含不合法的欄位資料描述。

class RequestException : BaseException {

    val invalidResult: Invalid<*>?

    constructor(
        code: ResponseCode,
        message: String? = "",
        cause: Throwable? = null,
        dataMap: Map<String, Any>? = null,
        tenantId: TenantId? = null
    ) : super(code, message, cause, dataMap, tenantId) {
        this.invalidResult = null
    }

    constructor(code: ResponseCode, invalidResult: Invalid<*>) : super(code, invalidResult.toString()) {
        this.invalidResult = invalidResult
    }
}

然後我們可以使用 Ktor StatusPages Plugin 捕捉例外,最後轉為 error response json 回應給 client 端

install(StatusPages) {
    val responseCreator = get<I18nResponseCreator>()

    exception<Throwable> { cause ->
        val e = ExceptionUtils.wrapException(cause)
        val errorResponse = responseCreator.createErrorResponse(e, call)
        call.respond(errorResponse)
    }
}

在這裡要考慮當 request data 格式有錯時,例如 body 的 json 格式有誤,此時在還沒進入到我們自己的 validator 時,就會先被 ktor 丟出例外了,然後就回傳非預期的錯誤給 client 端。因為 Ktor 文件並沒有寫會丟出什麼例外,所以我自己 try and error 搭配 trace code,實作以下的 wrapException 方法轉換為我自己定義的 RequestException

fun wrapException(e: Throwable): BaseException = when (e) {
    is BaseException -> e //只有這個是我自己定義的 Exception
    is ParameterConversionException -> RequestException(InfraResponseCode.BAD_REQUEST_PATH_OR_QUERYSTRING, e.message, e)
    is MissingRequestParameterException -> RequestException(InfraResponseCode.BAD_REQUEST_PATH_OR_QUERYSTRING, e.message, e)
    is BadRequestException -> RequestException(InfraResponseCode.BAD_REQUEST, e.message, e)
    is LocationRoutingException -> RequestException(InfraResponseCode.BAD_REQUEST_PATH_OR_QUERYSTRING, e.message, e)
    is kotlinx.serialization.SerializationException -> RequestException(InfraResponseCode.BAD_REQUEST_BODY, e.message, e)
    else -> InternalServerException(InfraResponseCode.UNEXPECTED_ERROR, cause = e)
}

最後再實作 ResponseCreator 負責把 Exception 轉為 ErrorResponseDTO,其中 message 是給一般使用者看的訊息,所以會根據客戶端的偏好語言回應訊息,後續我會再介紹我是如何實作 i18n 機制。至於 detail 是給前端開發者看的錯誤詳細訊息

class I18nResponseCreator(private val messagesProvider: ResponseMessagesProvider) {

    fun createErrorResponse(e: BaseException, call: ApplicationCall): ErrorResponseDTO {
        val messages = messagesProvider.preferred(call.lang())
        return ErrorResponseDTO(
            e.code,
            messages.getMessage(e), messages.getDetailMessage(e),
            call.callId!!, e.dataMap?.toJsonObject(), null
        )
    }
}

@Serializable
@SerialName("error")
class ErrorResponseDTO(
    override val code: ResponseCode,
    override val message: String,
    val detail: String,
    val reqId: String,
    override val data: JsonObject? = null,
    val errors: MutableList<ErrorResponseDetailError>? = null
) : ResponseDTO()

這2天已經說明 Ktor 處理請求的基本流程,包含正確執行時回應資料及錯誤時的例外處理,明天開始會更深入 Ktor ,說明如何整合 Koin DI 實作 Ktor plugin


上一篇
[Day 6] 使用 kotlinx.serialization 轉換 JSON
下一篇
[Day 8] 整合 Koin DI 實作 Ktor Plugin
系列文
基於 Kotlin Ktor 建構支援模組化開發的 Web 框架30

尚未有邦友留言

立即登入留言