iT邦幫忙

2021 iThome 鐵人賽

DAY 15
0
Modern Web

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

[Day 15] 實作 OpenAPI Plugin 產生 API 文件

為什麼我想自己實作 Ktor OpenAPI Generator?

大多數的 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,目標是…

  • 以編程取代 Annotation 的方式撰寫 API 文件於獨立的 kt 檔案,讓 route 程式碼看起來更簡潔
  • 支援 multi-project 架構,每個子專案可以產生各自的 API 文件
  • 整合框架其它功能,例如 Authentication 及 Autorization,自動在 API 加上驗證方式及角色權限的描述文字,開發者不需要再逐一為每個 API 寫描述,達到實際運作的程式碼即文件的目標
  • 實作「建置部署階段就把 Git 版本資訊加進文件」、「API 文件權限控管」…等加值功能,解決實務上使用 OpenAPI 會遇到的問題
  • 轉換 OpenAPI 文件為 Postman 測試腳本,進行 API 自動化測試

實作 Ktor OpenAPI Plugin

我們先了解 OpenAPI Generator 及 Swagger UI 的運作方式才知道要如何實作

  1. 為 route 撰寫 OpenAPI definition,讓 Generator 可以辨識解析,最後再序列化為 openapi.json
  2. Server 端安裝 Swagger UI,使用者透過瀏覽器
    1. 瀏覽器發送 GET 請求取得 Swagger UI 的 index.html
    2. 載入網頁時送出 GET 請求取得 openapi json
    3. 根據 openapi json render 網頁

撰寫 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)
        }
    }
}

建置部署階段就把 Git 版本資訊加進文件

假設在沒有 CI/CD 的環境下,只有部署後端 API Server 而已,我們要怎麼知道目前在 Server 上的程式版本呢? 延伸的相關問題還有

  • QA 回報 Bug 時、前端串接 API 時,怎麼知道當下 「API 的版本號」?
  • 除了版本號還可以再加註「部署環境名稱」及「程式更新時間」嗎?
  • 最後可以確保每次部署時都記得更新文件嗎?

既然我們已經可以自動產生 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 文件權限控管,有些 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


上一篇
[Day 14] 實作 API Role-Based Authorization
下一篇
[Day 16] 以 Programmatic 取代 Annotation 的方式撰寫 OpenAPI 文件
系列文
基於 Kotlin Ktor 建構支援模組化開發的 Web 框架30

尚未有邦友留言

立即登入留言