[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


<<:  Day5# For loop

>>:  QUIC.cloud CDN 免费方案

Day28 vue.js搜寻栏 分页(pagination)功能

延续昨日 今天我们且战且走 首先先把最简单的排序专案方法搞定 先创一个sortby function...

【Day 28】Hook 08:useCallback

useCallback 如果父元件所传递的 props 包含 Object, 则在元件因状态改变而 ...

Day 26 : Github Actions

什麽是Github Actions呢?这是Github平台 在2019年上架的CI功能,使用Gith...

格线系统(2) DAY44

今天要先来介绍 Sass @each 与 map Sass map-get 这里要先宣告变数(有点类...

让WooCommerce的订单通知信里面的商品名称附带商品网址的程序码

使用WooCommerce架设购物网站,预设的订单通知信里面,「商品名称」都是「纯文字」呈现,并不会...