[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 格式的测试报告


<<:  Day8 GraphQL 介绍、在WordPress 上安装 WPGraphQL plugin

>>:  Day 7 情报收集 - Information Gathering (Network & Port scanners)

ShadowsocksR/SSR客户端

ShadowsocksR常被称为SSR、酸酸乳、小飞机(粉色)、纸飞机(粉色),是由“破娃酱”发起的...

【Day 07】欢迎来到实力至上主义的 Shellcode (上) - Windows x86 Shellcode

环境 Windows 10 21H1 Visual Studio 2019 NASM 2.14.02...

[Day02] swift & kotlin 都我的!双平台史诗级 爱恨纠葛♥

iOS 与 Android 开发语言的爱恨纠葛 竟然要开始学习双平台语言 就让我们一起来了解这两款语...

DAY24神经网路(续二)

昨天介绍完单层感知机模型程序,今天要来研究浅层神经网路: 单层感知机模型是只有一个输入层和输出层,如...

[Day12] 建立订单交易API_5

本节将继续实作内文加密,程序如下 def aes_encrypt(key, content, iv)...