iT邦幫忙

2021 iThome 鐵人賽

DAY 17
0
Modern Web

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

[Day 17] 實作 Ktor OpenAPI Generator

先前有提到整個 OpenAPI 的運作流程是…

  1. 開發者為 route 撰寫 OpenAPI definition
  2. Generator 根據 OpenAPI Spec 把 definition 序列化為 openapi.json
  3. Swagger UI 讀取 openapi.json 顯示文件

我們已經在前2天完成步驟1及3,今天要探討如何實作步驟2的 OpenAPI Generator。因為 Generator 是根據 OpenAPI Spec 實作的,所以先簡介一下 Spec 再進入實作部分

根據 OpenAPI Spec 實作 Generator

OpenAPI Spec 可以是 JSON 或 YAML 格式,Swagger UI 是使用適合電腦讀取的 JSON 格式,也是 Generator 所輸出的 openapi.json 的格式,Swagger Editor 則是使用 YAML 適合人類讀寫的格式。至於 Spec 版本,我是根據 3.0.3 版本實作的,3.0.x 也是目前的主流版本。

Swagger 官方有製作著名的 Petstore 範例可以學習,建議讀者可以快速看一下,對 spec 格式及內容有個粗淺的印象,如果之後對細節有疑問的話,再查詢 Sepc 文件即可

下面是 openapi.json 第一層的內容,對於產生器來說,除了 components 及 paths 有複雜的巢狀階層之外,openapi, info...等 object 內容還蠻簡單容易理解的,只要在對應的類別物件填入值,再序列化為 json 就好了,所以接下來的重點放在如何產生 componentspaths

{
    openapi: "3.0.3",
    info: {
        title: "ops API (dev)",
        description: "...",
        version: "1.0.0"
    },
    servers: [...],
    tags: [...],
    components: {...}, // 共用的 OpenAPI definition 
    paths: {...}     // API
}
class OpenAPIObject(
    val openapi: String = "3.0.3",
    val info: Info,
    val servers: List<Server>,
    val tags: List<Tag>
    // ...
)

components 內容是可共用的 OpenAPI definition,例如 2 個 GET 查詢 API 都使用一樣的分頁參數 q_pageIndex, q_itemsPerPage,那麼可以把 query parameters definition 內容放在 components 裡面,然後 paths 只要輸出 $ref 指向 components 的路徑即可,這樣子可大幅降低 openapi.json 的大小。除了 parameters 之外,component 還可以定義 headers, requestBodies, responses, schemas...等 definitions

paths: {
    /users: {
        get: {
            operationId: "FindUsers",
            tags: [...],
            summary: "FindUsers => Auth = [ops-service => [root]]",
            security: [...],
            parameters: [
                {
                    $ref: "#/components/parameters/q_pageIndex-optional"
                },
                {
                    $ref: "#/components/parameters/q_itemsPerPage-optional"
                }
            ],
            responses: {}
        }
    }
},
components {
    parameters: {
        q_pageIndex-optional: {
            in: "query",
            required: false,
            schema: {
            type: "integer"
            },
            description: "分頁查詢 => 需一併指定 q_itemsPerPage,回傳第 q_pageIndex 頁,每頁 q_itemsPerPage 筆",
            name: "q_pageIndex"
        },
        q_itemsPerPage-optional: {
            in: "query",
            required: false,
            schema: {
            type: "integer"
            },
            description: "分頁查詢 => 需一併指定 q_pageIndex,回傳第 q_pageIndex 頁,每頁 q_itemsPerPage 筆",
            name: "q_itemsPerPage"
        }
    }
}

以上對 Spec 有大概的認識之後,接下來要進入實作部分,我們先從只有一層的 path 及 query parameter 開始,然後再說明巢狀階層的 request body, response body。

點我連結到完整的 OpenAPI Generator 程式碼

透過 Ktor Location 類別產生 path, query parameter definition

我們可以使用 Ktor Location 把 path, query parameter 包裝成一個 data class,例如UUIDEntityIdLocation 包含 path entityIdDynamicQueryLocation 包含 query parameters q_fields, q_filter... 等。

route("${ClubConst.urlRootPath}/users") {

    authorize(ClubAuth.Admin) {

        put<UUIDEntityIdLocation, UpdateUserForm, Unit>(ClubOpenApi.UpdateUser) { _, form ->
            clubUserService.updateUser(form)
            call.respond(HttpStatusCode.OK)
        }

        // dynamicQuery 是支援「指定回傳欄位」、「指定查詢條件」、分頁、排序…等的 GET 查詢操作 
        dynamicQuery<UserDTO>(ClubOpenApi.FindUsers) { dynamicQuery ->
            call.respond(dynamicQuery.queryDB<UserDTO>())
        }
    }
}

@Location("/{entityId}")
data class UUIDEntityIdLocation(val entityId: UUID)

@Location("")
data class DynamicQueryLocation(
    val q_fields: String? = null,
    val q_filter: String? = null,
    val q_orderBy: String? = null,
    val q_offset: Long? = null,
    val q_limit: Int? = null,
    val q_pageIndex: Long? = null,
    val q_itemsPerPage: Int? = null,
    val q_count: Boolean? = null
) : Location()

Generator 的實作流程是

  1. 從 route function 的 refied type parameter 取得 UUIDEntityIdLocation 及 DynamicQueryLocation 的 KClass 物件
  2. ParameterObjectConverter 透過 reflection api 取得 KClass 的 primaryConstructor 裡面的屬性 kparameters,然後再轉換為對應的 ParameterObject definition 物件
  3. 序列化 ParameterObject 物件為 json
object ParameterObjectConverter {

    @KtorExperimentalLocationsAPI
    fun toParameter(locationClass: KClass<*>): List<ParameterObject> {
        val annotation = locationClass.annotations.first { it.annotationClass == Location::class } as? Location
            ?: error("[OpenAPI]: Location Class ${locationClass.qualifiedName} @Location annotation is required")

        val pathParameters = annotation.path.split("/").filter { it.startsWith("{") && it.endsWith("}") }
            .map { it.substring(1, it.length - 1) }.toSet()

        return locationClass.primaryConstructor!!.parameters.map { kParameter ->
            val propertyName = kParameter.name!!
            val propertyDef = SchemaObjectConverter.toPropertyDef(propertyName, kParameter.type.classifier as KClass<*>)
                ?: error("location ${locationClass.qualifiedName} property $propertyName cannot map to PropertyDef")
            if (pathParameters.contains(propertyName)) {
                ParameterObject(ParameterInputType.path, true, propertyDef)
            } else {
                ParameterObject(ParameterInputType.query, !kParameter.isOptional, propertyDef)
            }
        }
    }
}

最終顯示結果

產生 Request Body, Response Body 的 definition

接下來是實作具有巢狀階層的 request body 及 response body

route("${ClubConst.urlRootPath}/users") {

    authorize(ClubAuth.Admin) {

        put<UUIDEntityIdLocation, UpdateUserForm, Unit>(ClubOpenApi.UpdateUser) { _, form ->
            clubUserService.updateUser(form)
            call.respond(HttpStatusCode.OK)
        }

        // dynamicQuery 是支援「指定回傳欄位」、「指定查詢條件」、分頁、排序…等的 GET 查詢操作 
        dynamicQuery<UserDTO>(ClubOpenApi.FindUsers) { dynamicQuery ->
            call.respond(dynamicQuery.queryDB<UserDTO>())
        }
    }
}

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

data class UserDTO(@JvmField @Serializable(with = UUIDSerializer::class) val id: UUID) : EntityDTO<UUID> {

    var account: String? = null
    var enabled: Boolean? = null
    var role: ClubUserRole? = null

    var name: String? = null
    var gender: Gender? = null

    var birthYear: Int? = null

    var email: String? = null
    var mobile: String? = null
    var lang: Lang? = null

    @Transient
    var password: String? = null

    @Serializable(with = InstantSerializer::class)
    var createdAt: Instant? = null

    // 巢狀階層
    var devices: List<UserDeviceDTO>? = null
}

@Serializable
data class UserDeviceDTO(@JvmField @Serializable(with = UUIDSerializer::class) val id: UUID) : EntityDTO<UUID> {

    @Serializable(with = UUIDSerializer::class)
    var userId: UUID? = null
    var sourceType: PrincipalSourceType? = null
    var enabled: Boolean? = null
    var pushToken: String? = null
    var osVersion: String? = null
    var userAgent: String? = null

    @Serializable(with = InstantSerializer::class)
    var enabledAt: Instant? = null
}

Generator 的實作流程是

  1. 從 route function 的 refied type parameter 取得 UpdateUserFormUserDTO 的 KType 物件
  2. SchemaObjectConverter 透過 reflection api,遞迴轉換為各種 schema definition 物件。遞迴處理的順序是
    1. 先判斷是不是已經定義為共用的 components,如果是則直接輸出 $ref 參考路徑
    2. 是不是到達最底層節點的 Property,例如 Primitive Type, UUID, java.time 的 LocalDate…等基本型態
    3. 是不是 Array,如果裡面是物件 Model,則再進入下一層
    4. 最後一定是物件 Model,所以進入下一層
  3. 序列化 schema definition 物件為 json

點我連結到完整的 SchemaObjectConverter 程式碼

object SchemaObjectConverter {

    private val propertyConverters: MutableMap<KClass<*>, (String) -> PropertyDef> = mutableMapOf()

    fun toSchema(components: ComponentsObject, modelKType: KType, modelName: String? = null): Schema {
        val name = modelName ?: getSchemaName(modelKType)
        val modelClass = modelKType.classifier as KClass<*>
        return components.getSchemaRef(modelClass)
            ?: toPropertyDef(name, modelClass)
            ?: getArrayDefKType(modelKType)?.let { toArrayDef(components, name, it) }
            ?: toModelDef(components, name, modelKType)
    }
    // 其餘省略
}

最終顯示結果,我們可以看到 devices 是 array of json objects

建立 Definition 的 ReferenceObject,避免產生重複的 definition

因為 schema 要支援 components 共用的 definition,也就是 json 要輸出 $ref,而非整個 definition 物件,所以我定義 Element interface 及 ReferenceObject class,又因為 ReferenceObject 可以指向任一種 schema definition,所以 ReferenceObject 實作了所有 schema interface => Element, Header, Parameter, RequestBody, Response, Schema, Example。透過這個手法,可以把 Definition 及 ReferenceObject 一視同仁處理,程式寫起來比較乾淨。

interface Element : Identifiable<String> {

    val name: String

    override fun getId(): String = name

    fun getDefinition(): Definition

    fun getReference(): ReferenceObject

    fun createRef(): ReferenceObject

    fun refPair(): Pair<String, ReferenceObject> = name to getReference()

    fun defPair(): Pair<String, Definition> = name to getDefinition()

    fun valuePair(): Pair<String, Element>
}

interface Parameter : Element
interface Header : Parameter
interface RequestBody : Element
interface Response : Element
interface Schema : Element
interface Example : Element

abstract class Definition(
    @JsonIgnore override val name: String,
    @JsonIgnore val refName: String? = null
) : Element {

    abstract fun componentsFieldName(): String

    @JsonIgnore
    override fun getId(): String = "/${componentsFieldName()}/$name"

    @JsonIgnore
    override fun getDefinition(): Definition = this

    @JsonIgnore
    override fun getReference(): ReferenceObject =
        if (refObj.isInitialized()) refObj.value
        else error("${getId()} referenceObject is not initialized")

    // lazy initialization to avoid reference infinite cycle
    private val refObj: Lazy<ReferenceObject> = lazy {
        ReferenceObject(refName ?: name, this)
    }

    fun hasRef(): Boolean = refObj.isInitialized()

    override fun createRef(): ReferenceObject = refObj.value

    fun createRef(refName: String): ReferenceObject = ReferenceObject(refName, this)

    override fun equals(other: Any?): Boolean = idEquals(other)
    override fun hashCode(): Int = idHashCode()
}

class ReferenceObject(
    override val name: String,
    @JvmField private val definition: Definition
) : Element, Header, Parameter, RequestBody, Response, Schema, Example {

    val `$ref` = "#/components/${definition.componentsFieldName()}/${definition.name}"

    override fun getDefinition(): Definition = definition

    override fun getReference(): ReferenceObject = this

    override fun createRef(): ReferenceObject = this

    override fun valuePair(): Pair<String, Element> = name to this

    @JsonValue
    fun toJson(): JsonNode = Jackson.newObject().put("\$ref", `$ref`)

    override fun equals(other: Any?): Boolean = idEquals(other)
    override fun hashCode(): Int = idHashCode()
}

// 以下省略各種 Definnition 子類別 RequestBodyObject, ResponseObject, ParameterObject... 

多個子專案共用 definition

在多專案架構下,底層的 infra module 及子專案都可以定義 components,所以當我們產生某一個子專案的 OpenAPI 文件時,components 要包含 infra module 的BuiltinComponents 及子專案自己的 components。例如下圖中的 InfraResponseCode 會列出所有子專案共用的回應碼

Ops 子專案除了列出上述 infra module 的 InfraResponseCode,還會再列出 Ops 子專案自己的 UserType, NotificationType 及 OpsResponseCode

使用 Swagger Editor 除錯

雖然 Geneartor 產出的格式是 json,但我們可以在 Swagger Editor 匯入 openapi.json 檔案,如果 openapi.json 的內容有錯誤,Swagger Editor 會顯示錯誤訊息,可以當作 debug 工具使用,然後我們再依此修正程式。

結語

今天重點式摘要說明如何實作 OpenAPI Generator,其餘實作細節實在太多,無法在一天之內講完。如果讀者有興趣的話,可以到 Github 查看完整程式碼

經過這 3 天的努力,現在我們已經能自動產生 OpenAPI 文件了,但是這樣還不夠! 明天我們再進一步把 OpenAPI 文件轉換為 Postman 測試腳本,也就是測試程式也要能自動產生,最後再執行 API 自動化測試,產出 HTML 格式的測試報告


上一篇
[Day 16] 以 Programmatic 取代 Annotation 的方式撰寫 OpenAPI 文件
下一篇
[Day 18] 轉換 OpenAPI 文件為 Postman Collection 做 Web API 自動化測試
系列文
基於 Kotlin Ktor 建構支援模組化開發的 Web 框架30

尚未有邦友留言

立即登入留言