先前有提到整个 OpenAPI 的运作流程是…
我们已经在前2天完成步骤1及3,今天要探讨如何实作步骤2的 OpenAPI Generator。因为 Generator 是根据 OpenAPI Spec 实作的,所以先简介一下 Spec 再进入实作部分
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 就好了,所以接下来的重点放在如何产生 components
及 paths
。
{
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 包装成一个 data class,例如UUIDEntityIdLocation
包含 path entityId
,DynamicQueryLocation
包含 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 的实作流程是
ParameterObject
definition 物件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
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 的实作流程是
UpdateUserForm
及 UserDTO
的 KType 物件点我连结到完整的 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
因为 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...
在多专案架构下,底层的 infra module 及子专案都可以定义 components,所以当我们产生某一个子专案的 OpenAPI 文件时,components 要包含 infra module 的BuiltinComponents
及子专案自己的 components。例如下图中的 InfraResponseCode 会列出所有子专案共用的回应码
Ops 子专案除了列出上述 infra module 的 InfraResponseCode,还会再列出 Ops 子专案自己的 UserType, NotificationType 及 OpsResponseCode
虽然 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、酸酸乳、小飞机(粉色)、纸飞机(粉色),是由“破娃酱”发起的...
环境 Windows 10 21H1 Visual Studio 2019 NASM 2.14.02...
iOS 与 Android 开发语言的爱恨纠葛 竟然要开始学习双平台语言 就让我们一起来了解这两款语...
昨天介绍完单层感知机模型程序,今天要来研究浅层神经网路: 单层感知机模型是只有一个输入层和输出层,如...
本节将继续实作内文加密,程序如下 def aes_encrypt(key, content, iv)...