[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 10-不专业介绍package.jason

>>:  Day 2: Nativescript底层架构

[Day14] 团队管理:建立团队信赖感(3)

有意义的讨论 层层拆解,找到歧异点 讨论过程里面,我们通常容易跳到结论做为起点,而我们通常也会先看到...

[Day11] swift & kotlin 实作篇!(2) 建立专案

swift 开启Xcode, 点选 Create a new Xcode project swift...

D13 - 用 Swift 和公开资讯,打造投资理财的 Apps { 加权指数K线图实作.1 }

目标: 做出台湾加权指数 K 线图 之前做出来的台股申购是独立的功能,为了不影响前面已经完成的功能,...

Day 10 Summary 2

Introduction to embedded system Role of embedded s...

[Day07] 赋值运算子、逗号运算子、逻辑运算子笔记

赋值运算子(Assignment operators) 在 Javascript 里运算有递增的写法...