先前有提到整個 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 格式的測試報告