iT邦幫忙

2021 iThome 鐵人賽

DAY 12
0
Modern Web

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

[Day 12] 實作 API Response 及 i18n Response Message

定義 API Response 格式

API Response 的格式沒有標準答案,網路上已經有許多範例可以參考,我認為不管格式為何,重點是團隊成員有共識,而且相同類型的 API 格式要固定,否則會造成串接方的困擾。如果你還沒決定好格式,不妨可以參考我的作法。

基本的 ResponseDTO 包含以下欄位

  • ResponseCode
    • name: 方便理解的名稱
    • value: 固定不變的回應碼
    • type: 回應碼類型 (ResponseCodeType)
    • httpStatusCode: HTTP 狀態碼
  • message: 給使用者看的訊息文字,支援 i18n
  • data: 回應資料 (json)

簡單的成功格式有 CodeResponseDTODataResponseDTO,CodeResponseDTO 只回應 ResponseCode,DataResponseDTO 則是有回應資料 data

至於錯誤格式的 ErrorResponseDTO 多了以下欄位

  • detail: 給前端開發者看的詳細錯誤訊息
  • reqId: 請求ID (如果發生錯誤,我們可以拿這個 id 去搜尋 error log)
  • errors: 如果有多個詳細錯誤訊息需要回應給前端

例如使用者登入帳密錯誤,此時回應 AUTH_LOGIN_UNAUTHENTICATED 的 type 是 CLIENT_INFO ,前端可以顯示 INFO 等級的訊息文字給使用者。

{
  "type": "error",
  "code": {
    "name": "AUTH_LOGIN_UNAUTHENTICATED",
    "value": "2008",
    "type": "CLIENT_INFO"
  },
  "message": "帳號或密碼不正確",
  "detail": "[AUTH_LOGIN_UNAUTHENTICATED] ",
  "reqId": "3gzwul5brn13-n-leu51bdknmt9+vi31"
}

如果前端不小心漏掉檢查,傳給後端不合法的資料時,此時回應 BAD_REQUEST_BODY 的 type 是 CLIENT_ERROR,前端可以進行程式修正,如果情境上無法驗證資料,那就顯示錯誤訊息給使用者。

{
  "type": "error",
  "code": {
    "name": "BAD_REQUEST_BODY",
    "value": "1004",
    "type": "CLIENT_ERROR"
  },
  "message": "系統無法處理您的請求或請求結果有錯誤",
  "detail": "請求的 body 資料格式有錯誤 => [BAD_REQUEST_BODY] Invalid(errors=[ValidationError(dataPath=.account, message=must match the expected pattern)])",
  "reqId": "ddsoy+ip2t0vupyrz54bqdujk+6bsgo9"
}

另外還有 PagingDataResponseDTO 用於分頁, BatchResponseDTO 用於批次工作…等其它格式,完整程式碼可以參考 Github Repo

@Serializable
class ResponseCode(
    val name: String,
    val value: String,
    val type: ResponseCodeType,
    @Transient val httpStatusCode: HttpStatusCode = HttpStatusCode.OK
)

enum class ResponseCodeType {
    SUCCESS,
    CLIENT_INFO, // 存在回應給使用者的訊息
    CLIENT_ERROR, // 前端請求錯誤
    SERVER_ERROR; // 後端處理錯誤
}

@Serializable
sealed class ResponseDTO {
    abstract val code: ResponseCode
    abstract val message: String?
    abstract val data: JsonElement?
}

@Serializable
@SerialName("code")
class CodeResponseDTO(override val code: ResponseCode) : ResponseDTO() {

    override val message: String? = null
    override val data: JsonElement? = null

    companion object {
        val OK = CodeResponseDTO(InfraResponseCode.OK)
    }
}

@Serializable
@SerialName("data")
class DataResponseDTO(
    override val code: ResponseCode = InfraResponseCode.OK,
    override val message: String? = null,
    override val data: JsonElement
) : ResponseDTO()

@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()

@Serializable
class ErrorResponseDetailError(
    val code: ResponseCode,
    val detail: String,
    val data: JsonObject? = null
)

@Serializable
@SerialName("paging")
class PagingDataResponseDTO(
    override val code: ResponseCode = InfraResponseCode.OK,
    override val message: String? = null,
    override val data: JsonElement
) : ResponseDTO() {

    @Serializable
    class PagingData(
        val total: Long, val totalPages: Long,
        val itemsPerPage: Int, val pageIndex: Long,
        val items: JsonArray
    )
}

@Serializable
@SerialName("batch")
class BatchResponseDTO(
    override val code: ResponseCode,
    override val message: String,
    override val data: JsonObject
) : ResponseDTO()

@Serializable
class BatchResult(val successes: MutableList<SuccessResult>, val failures: MutableList<FailureResult>)

@Serializable
class SuccessResult(override val id: String, val data: JsonElement? = JsonObject(mapOf())) :
    IdentifiableObject<String>()

@Serializable
class FailureResult(override val id: String, val errors: MutableList<ErrorResponseDetailError>) : IdentifiableObject<String>()

支援多專案的 i18n Response Message

我習慣 API 回應的訊息文字區分為給一般使用者看的 message 屬性及給前端開發者看的 detail 屬性,其中 message 應該要支援 i18n。昨天提到的多專案 i18n 訊息通知範例,每個子專案可以定義自己的 NotificationType,而且擁有自己的訊息通知語系檔 notification_zh-TW.prperties。同樣地,每個子專案擁有自己的 API,所以每個 API 回應給使用者的訊息文字,也需要可以定義在自己的語系檔 response_zh-TW.conf (HOCON格式)。但是這兩個功能有一個地方不同的是,雖然一樣是多專案架構,但對於許多 ResponseCode 而言是可以讓子專案共用的,例如上面提到的登入失敗 AUTH_LOGIN_UNAUTHENTICATED 及資料驗證錯誤 BAD_REQUEST_BODY

我在底層 infra module 定義這些一般用途可共用的 InfraResponseCode,另外在2個子專案定義各自的 OpsResponseCodeClubResponseCode。要注意所有 ResponseCode 的 value 值是全域性的不能重複。

object InfraResponseCode {
    val OK = ResponseCode("OK", "0000", ResponseCodeType.SUCCESS, HttpStatusCode.OK)
    val BAD_REQUEST_BODY = ResponseCode("BAD_REQUEST_BODY", "1004", ResponseCodeType.CLIENT_ERROR, HttpStatusCode.BadRequest)
    val AUTH_LOGIN_UNAUTHENTICATED = ResponseCode("AUTH_LOGIN_UNAUTHENTICATED", "2008", ResponseCodeType.CLIENT_INFO, HttpStatusCode.Unauthorized)
    // 其它省略
}

object OpsResponseCode {
    val OPS_ERROR = ResponseCode("OPS_ERROR", "3000", ResponseCodeType.SERVER_ERROR, HttpStatusCode.InternalServerError)
}

object ClubResponseCode {
    val CLUB_ERROR = ResponseCode("CLUB_ERROR", "4000", ResponseCodeType.SERVER_ERROR, HttpStatusCode.InternalServerError)
}

然後 ResponseCode value 當作 key 對應到訊息文字,以下是各個 response_zh-TW.conf 語系檔內容

// ===== infra module =====
codeType {
  SUCCESS = "操作成功",
  USER_FAILED = "操作結果失敗",
  CLIENT_ERROR = "系統無法處理您的請求或請求結果有錯誤",
  SERVER_ERROR = "系統錯誤"
}
code {
  0000 = "操作成功",
  1004 = "請求的 body 資料格式有錯誤",
  2008 = "帳號或密碼不正確"
}

// ===== ops project =====
code {
    3000 = "Ops 錯誤"
}

// ===== club project =====
code {
    4000 = "Club 錯誤"
}

依照我的 i18n 機制的作法,實作 ResponseMessages 及其 ResponseMessagesProvider,其中 ResponseMessagesProvider 的 merge 方法會把所有專案的 ResponseMessages 都合併至同一個物件方便操作,底層是透過 com.typesafe.config.ConfigwithFallback 方法實現,這也是我為什麼採用 HOCON 語系檔格式的原因。

class ResponseMessages(val messages: HoconMessagesImpl) : Messages {

    fun getMessage(ex: BaseException): String =
        if (ex.code.isError()) getCodeTypeMessage(ex.code.type)
        else getCodeMessage(ex)

    fun getDetailMessage(ex: BaseException): String {
        val message = if (ex.code.isError()) getCodeMessage(ex) else ""
        return ex.message?.let { if (message.isNotEmpty()) "$message => $it" else it } ?: message
    }

    private fun getCodeTypeMessage(codeType: ResponseCodeType): String {
        return get("codeType.${codeType.name}", null)!!
    }

    private fun getCodeMessage(ex: BaseException): String {
        return if (ex is EntityException) {
            val args: MutableMap<String, Any> = mutableMapOf()

            if (ex.entity != null) {
                args.putAll(ex.entity.toNotNullMap("entity"))
            }

            if (ex.dataMap != null) {
                args.putAll(ex.dataMap)
            }
            getCodeMessage(ex.code, args)
        } else {
            getCodeMessage(ex.code, ex.dataMap)
        }
    }

    private fun getCodeMessage(code: ResponseCode, args: Map<String, Any>? = null): String {
        val message = get("code.${code.value}", args)
        if (code.type == ResponseCodeType.CLIENT_INFO)
            requireNotNull(message)
        return message ?: ""
    }

    override val lang: Lang = messages.lang

    override fun get(key: String, args: Map<String, Any>?): String? = messages.get(key, args)

    override fun isDefined(key: String): Boolean = messages.isDefined(key)
}

class ResponseMessagesProvider(messagesProvider: HoconMessagesProvider) : MessagesProvider<ResponseMessages> {

    private val logger = KotlinLogging.logger {}

    override val messages: Map<Lang, ResponseMessages> = messagesProvider.messages
        .mapValues { ResponseMessages(it.value) }

    fun merge(another: HoconMessagesProvider) {
        messages.forEach { (lang, responseMessages) ->
            another.messages[lang]?.let {
                responseMessages.messages.withFallback(it)
            }
        }
    }
}

然後 I18nResponseCreator 負責把 Exception 轉換為 ErrorResponseDTO,並且根據客戶端請求的偏好語言,讀取對應語系檔的訊息文字,再填入到 message 屬性。

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
        )
    }
}

取得 HTTP Request 偏好語言是透過在 Ktor ApplicationCallApplicationRequest 類別定義lang() extension function,從 cookie 或 header Accept-Language 取得。

fun ApplicationRequest.lang(): Lang? = (cookies["lang"] ?: acceptLanguageItems().firstOrNull()?.value)?.let { Lang(it) }

fun ApplicationCall.lang(): Lang? = attributes.getOrNull(Lang.ATTRIBUTE_KEY) ?: request.lang()

這 2 天說明如何建立 Ktor i18n 機制,還有在 multi-project 架構上實作 API Response Message 及 Notification Message。明天的主題將進入 Ktor API Authentication 及 Authorization。


上一篇
[Day 11] 實作 Ktor i18n 機制
下一篇
[Day 13] 實作 API Authentication
系列文
基於 Kotlin Ktor 建構支援模組化開發的 Web 框架30

尚未有邦友留言

立即登入留言