大多數的 Web 框架都有官方或社群開發的 OpenAPI Generator,自動把程式碼轉為 OpenAPI 文件。相對於 Spring Boot 有完整成熟的套件 springdoc-openapi,目前 Ktor 就只有2個 Github 不太成熟的套件,而且缺乏我想要的功能,使用方式也可以再改進。
另一方面,目前 Ktor YouTrack 上的 issue KTOR-774 仍處於 open 狀態,官方團隊在 2021-05-03 於 Slack 的回應是 We are investigating different ways on how to implement it using idiomatic kotlin and ktor style. No estimates yet.
所以看來短期是不會推出解決方案了。
綜合以上原因,我打算自己動手實作 OpenAPI Generator,目標是…
我們先了解 OpenAPI Generator 及 Swagger UI 的運作方式才知道要如何實作
撰寫 OpenAPI definition 及實作 Generator 等到明天再說明,在此我們先假設已經產生了 openapi.json 內容,然後要實作 Ktor OpenAPI Plugin 整合 Swagger UI (點此看完整程式碼)。
OpenAPI Plugin 先讀取設定檔的 openapi 設定,然後建立 ProjectOpenApiManager
物件,後續每個子專案會註冊自己的 openapi.json。接下來是建立 2 個 route,一個是下載 Swagger UI 的靜態檔案 js, css,另一個是下載 openapi.json,這樣就能透過 Swagger UI 瀏覽 API 文件了。
// application.conf
openApi {
swaggerUI {
// swagger-ui 靜態檔案 js, css 路徑,檔案會一併打包至 shadow fat jar 及 docker image
dir = ${SWAGGER_UI_PATH}
}
}
install(OpenApiFeature)
override fun install(pipeline: Application, configure: Configuration.() -> Unit): OpenApiFeature {
val configuration = Configuration().apply(configure)
val feature = OpenApiFeature(configuration)
val appConfig = pipeline.get<MyApplicationConfig>()
val openApiConfig = appConfig.infra.openApi ?: configuration.build()
val projectOpenApiManager = ProjectOpenApiManager(openApiConfig)
pipeline.koin {
modules(
module(createdAtStart = true) {
single { projectOpenApiManager }
}
)
}
pipeline.routing {
// 下載 openapi.json
get("/apidocs/schema/{schemaJsonFileName}") {
val schemaJsonFileName = call.parameters["schemaJsonFileName"]
?: throw RequestException(InfraResponseCode.BAD_REQUEST_PATH, "schema json file name is required")
val projectId = schemaJsonFileName.substringBefore(".")
call.respond(projectOpenApiManager.getOpenApiJson(projectId))
}
// 下載 swagger-ui 靜態檔案 js, css
static("apidocs") {
resources("swagger-ui")
files(swaggerUiDir)
}
}
}
// 支援 multi-project 架構
class ProjectOpenApiManager(val config: OpenApiConfig) {
private val openApiMap: MutableMap<String, ProjectOpenApi> = mutableMapOf()
private val openApiJsonMap: MutableMap<String, String> = mutableMapOf()
fun register(projectOpenApi: ProjectOpenApi) {
val projectId = projectOpenApi.projectId
require(!openApiMap.containsKey(projectId))
openApiMap[projectId] = projectOpenApi
projectOpenApi.init(config)
}
fun getOpenApiJson(projectId: String): String {
return openApiJsonMap.getOrPut(projectId) {
val openAPIObject = openApiMap[projectId]?.openAPIObject
?: throw RequestException(InfraResponseCode.ENTITY_NOT_FOUND, "$projectId openapi json not found")
Jackson.toJsonString(openAPIObject)
}
}
}
假設在沒有 CI/CD 的環境下,只有部署後端 API Server 而已,我們要怎麼知道目前在 Server 上的程式版本呢? 延伸的相關問題還有
既然我們已經可以自動產生 API 文件,那麼如果我們在建置部署階段就把 Git 版本資訊加進文件,以上問題就可以解決了。
我們先定義 git 資訊的相關設定值於 application.conf 及對應的 config 類別 OpenApiInfoConfig
openApi {
info {
env = "@env@"
gitTagVersion = "@gitTagVersion@"
gitCommitVersion = "@gitCommitVersion@"
buildTime = "@buildTime@"
description = ""
}
swaggerUI {
dir = ${SWAGGER_UI_PATH}
}
}
data class OpenApiConfig(
val info: OpenApiInfoConfig,
val swaggerUI: SwaggerUIConfig? = null
)
data class OpenApiInfoConfig(
val env: String,
val gitTagVersion: String,
val gitCommitVersion: String,
val buildTime: String,
val description: String = ""
)
另一方面,我們也安裝 Gradle Git Plugin,在 shadow plugin 執行 installShadowDist
task 打包檔案時,先讀取當下的 git branch 的 commit, tag 資訊、env 部署環境名稱,還有 buildTime 建置當下的時間。然後再用 Ant filter 替換 application.conf 設定檔裡的變數值,這樣 OpenAPI Plugin 就能透過設定檔讀取到 git 資訊了 (點此看完整 gradle build script)
plugins {
id("org.unbroken-dome.gitversion") version "0.10.0"
}
val branchToEnvMap: Map<String, String> = mapOf(
devBranchName to "dev",
testBranchName to "test",
releaseBranchName to "prod"
)
project.version = gitVersion.determineVersion()
val semVersion = (project.version as SemVersion)
val tagVersion = "${semVersion.major}.${semVersion.minor}.${semVersion.patch}"
val branch = semVersion.prereleaseTag!!
val env = branchToEnvMap[branch]
val shadowDistConfigFiles by tasks.register("shadowDistConfigFiles") {
group = "distribution"
doLast {
copy {
from("dist/config/$env")
into("src/dist")
filter<ReplaceTokens>(
"tokens" to mapOf(
"env" to env,
"gitTagVersion" to tagVersion,
"gitCommitVersion" to semVersion.toString(),
"buildTime" to DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").format(LocalDateTime.now())
)
)
}
}
}
在 OpenAPI Plugin 讀取 git 資訊之後,接下來就是想辦法顯示在 Swagger UI 的 description 區塊,因為 Swagger UI 本身是 html 網頁,所以我想使用 kotlinx.html library,以 Kotlin DSL 語法撰寫 html,建立 div block 再崁入到 openapi.json 的 description 屬性,經過排版後會比較美觀且清楚。
fun init(config: OpenApiConfig) {
openAPIObject = OpenAPIObject(
info = Info(
title = "$projectId API (${config.info.env})", description = buildInfoDescription(config),
version = config.info.gitTagVersion
),
servers = listOf(Server(url = urlRootPath)),
tags = operations.flatMap { it.tags }
)
}
private fun buildInfoDescription(config: OpenApiConfig): String {
return buildString {
appendHTML(false).div {
p {
+config.info.description
}
ul {
li {
+"Server Start Time: ${
DateTimeUtils.LOCAL_DATE_TIME_FORMATTER.format(LocalDateTime.now(DateTimeUtils.TAIWAN_ZONE_ID))
}"
}
li { +"Build Time: ${config.info.buildTime}" }
li { +"Git Commit Version: ${config.info.gitCommitVersion}" }
}
}
}
}
另一個實務上的問題是API 文件權限控管,有些 API 文件是不可以對外公開的,雖然可以限制內網存取,最好還是可以設定帳密進行控管
我們先定義帳號密碼設定值於 application.conf 及對應的 config 類別 SwaggerUIConfig
openApi {
swaggerUI {
dir = "/home/ec2-user/swagger-ui"
username = ${?SWAGGER_UI_AUTH_USER} #optional
password = ${?SWAGGER_UI_AUTH_PASSWORD} #optional
}
}
data class SwaggerUIConfig(
val dir: String?,
val username: String?,
val password: String?
)
然後在 OpenAPI Plugin 設定 Ktor Authentication Plugin,使用 HTTP Basic Authentication 保護 Swagger UI 的那2個 endpoints。
override fun install(pipeline: Application, configure: Configuration.() -> Unit): OpenApiFeature {
val configuration = Configuration().apply(configure)
val feature = OpenApiFeature(configuration)
val appConfig = pipeline.get<MyApplicationConfig>()
val openApiConfig = appConfig.infra.openApi ?: configuration.build()
val swaggerUIConfig = openApiConfig.swaggerUI
val swaggerUIAuth = swaggerUIConfig?.needAuth ?: false
if (swaggerUIAuth) {
requireNotNull(swaggerUIConfig)
pipeline.feature(Authentication).apply {
configure {
basic(swaggerUIAuthProviderName) {
realm = swaggerUIAuthProviderName
validate { userPasswordCredential ->
if (swaggerUIConfig.username == userPasswordCredential.name &&
swaggerUIConfig.password == userPasswordCredential.password
) UserIdPrincipal(userPasswordCredential.name) else null
}
}
}
}
}
pipeline.routing {
if (swaggerUIAuth) {
authenticate(swaggerUIAuthProviderName) {
apiDocsRoute(this, swaggerUIConfig, projectOpenApiManager)
}
} else {
apiDocsRoute(this, swaggerUIConfig, projectOpenApiManager)
}
}
}
今天說明如何實作 OpenAPI Plugin 整合 swaggerUI 產生 API 文件,明天再深入探討開發者如何為 ktor route 撰寫 OpenAPI Definition