[Day 16] 以 Programmatic 取代 Annotation 的方式撰写 OpenAPI 文件

Spring Boot 使用 Annotation 撰写 OpenAPI Definition

我们先来看 spring boot 撰写 OpenAPI definition 的方式,参考 Baeldung 这篇教学文章 spring-rest-openapi-documentation 的范例,只要先 import springdoc-openapi 套件,然後在 controller method 加上 annotation 即可标注 OpenAPI definition。这种以 annotation 增加扩充功能的方式,是 java 世界中许多框架的主流做法,这是因为 annotation 不是 java method signature 的一部分,所以侵入性低不影响编译。另一方面,框架函式库作者只要先为复杂的实作提供对应的 annotation,使用者就可以透过 annotation 传入设定值,不仅低耦合,还具有类似 declarative 风格的效果。

虽然加 annotation 很方便,但使用 annotation 的方式也不是没有缺点,以上述 Baeldung 教学文章中的范例,一眼看过去,是不是有点找不到 controller method 的实作程序码在那,这是因为 openAPI definition 的属性有很多,如果要写很详细的文件,势必要加上很多 annotation,而且还是有巢状阶层的。另一个缺点是 annotation member 仅支援 primitive type, String, enum…等少数型态,所以如果值是需要运算操作才能得出的,那就没办法了。

@Operation(summary = "Get a book by its id")
@ApiResponses(value = { 
        @ApiResponse(responseCode = "200", description = "Found the book", 
            content = { @Content(mediaType = "application/json", schema = @Schema(implementation = Book.class)) }),
        @ApiResponse(responseCode = "400", description = "Invalid id supplied", content = @Content), 
        @ApiResponse(responseCode = "404", description = "Book not found", content = @Content) }) // @formatter:on
@GetMapping("/{id}")
public Book findById(@Parameter(description = "id of book to be searched") @PathVariable long id) {
    return repository.findById(id)
        .orElseThrow(() -> new BookNotFoundException());
}

另一种是 Spring 5 才支援的 Functional DSL 写法,以下面 spring-openapi 文件中的范例来看,我们可以使用 functional API 取代 @RouterOperations,不必受到 annotation 的限制。另一方面,虽然看起来程序码还是很多,但我们可以把 SpringdocRouteBuilder 的内容抽出来成为另一个 method,就可以看起来比较简洁。

不过有个限制是在 route function 就一定要呼叫 SpringdocRouteBuilder 的 build() 方法回传 RouterFunction 了,也就是所有 OpenAPI definition 在此阶段就要全部撰写完毕,而且也因为没有其它像是 @PathVariable annotation 的辅助,也不能使用 Reflection 剖析 route function signature,所以必须要从头到尾完整撰写 definition 了。

@Bean
RouterFunction<?> routes() {
    return route().GET("/foo", HANDLER_FUNCTION, ops -> ops
            .operationId("hello")
            .parameter(parameterBuilder().name("key1").description("My key1 description"))
            .parameter(parameterBuilder().name("key2").description("My key2 description"))
            .response(responseBuilder().responseCode("200").description("This is normal response description"))
            .response(responseBuilder().responseCode("404").description("This is another response description"))
    ).build();
}

Ktor 透过编程撰写 OpenAPI Definition

因为 Kotlin 比起 Java 多了 extension function, reified type parameter 的特性,再搭配 Ktor Route DSL 风格的语法,所以在 route function 撰写 OpenAPI definition 看起来会再更简洁。

我根据 post, put, get 不同的 route,定义对应的 extension function,包含 3 个 reified type parameter

  • LOCATION: 包含 path, query parameter 的 Ktor Location 类别
  • REQUEST: Request Body 类别
  • RESPONSE: Response Body 类别

至於 function parameter 是我事先定义在另一个 kt 档案的 OpenApiOperation 物件,详细的 OpenAPI definition 都会写在这里,与 route function 分开。

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>())
        }
    }
}

以下是对应的 Route extension function 实作程序码

OpenApiOperation 物件会呼叫 bindRoute() 方法,根据当下的 route 物件及 refied type parameter 产生 path, query parameter, requestbody, response body…等 definition。

// === http PUT operation ===
@ContextDsl
inline fun <reified LOCATION : Location, reified REQUEST : Form<*>, reified RESPONSE : Any> Route.put(
    operation: OpenApiOperation,
    noinline body: suspend PipelineContext<Unit, ApplicationCall>.(LOCATION, REQUEST) -> Unit
): Route {
    operation.bindRoute(
        this, null, HttpMethod.Put,
        typeOf<REQUEST>(), typeOf<RESPONSE>(), LOCATION::class
    )
    return locationPut<LOCATION> {
        body(this, it, call.receiveAndValidateBody(it as? Location))
    }
}

// === http GET operation ===
@ContextDsl
inline fun <reified RESPONSE : EntityDTO<*>> Route.dynamicQuery(
    operation: OpenApiOperation,
    noinline body: suspend PipelineContext<Unit, ApplicationCall>.(DynamicQuery) -> Unit
): Route {
    operation.bindRoute(
        this, null, HttpMethod.Get,
        typeOf<Unit>(), typeOf<RESPONSE>(), DynamicQueryLocation::class
    )
    return locationGet<DynamicQueryLocation> {
        it.validate()
        body(this, DynamicQuery.from(it))
    }
}

class OpenApiOperation(
    override val id: String,
    val tags: List<Tag>,
    private val notYetImplemented: Boolean = false,
    deprecated: Boolean = false,
    private val configure: (OperationObject.() -> Unit)? = null
)

因为 bindRoute() 已经建立了 requestbody, response body...等 definition 物件,所以在 OpenApiOperation 的 configure trailing lambda 里面,只要写额外想补充的部分就好。例如我想在 Login API 补充 API 的错误码有可能是「帐号被停用」或是「帐密不正确」,其它还有 request, response 的范例物件。

这种使用编程而不是 annotation 的好处是

  • 可以先实作 addErrorResponses, addRequestExample …等 utility method,不必直接操作底层的 definition 物件,不仅撰写文件更快速,还更简洁可客制化。
  • Request, Response Body 的 example 物件 AppLoginForm, AppLoginResponse 就是 API 实际运作的类别物件,所以如果类别有任何异动,例如多增加一个栏位,那我们可以很轻易透过 IDE 找出所有的物件进行修改,不会发生漏改文件的情况,达到程序码即文件的目标
// === club 子专案的 OpenAPI 文件 kt 档案 ===
object ClubOpenApi {

    val Login = OpenApiOperation("Login", listOf(AuthTag)) {

        addErrorResponses(
            InfraResponseCode.AUTH_PRINCIPAL_DISABLED,
            InfraResponseCode.AUTH_LOGIN_UNAUTHENTICATED
        )

        addRequestExample(
            AppLoginForm(
                "[email protected]", "test123", null,
                UUID.randomUUID(), "pushToken", "Android 9.0"
            )
        )

        addResponseExample(
            InfraResponseCode.OK,
            ExampleObject(
                ClientVersionCheckResult.Latest.name, ClientVersionCheckResult.Latest.name, "已是最新版本",
                DataResponseDTO(
                    AppLoginResponse(
                        "club:android:user:421feef3-c1b4-4525-a416-6a11cf6ed9ca:2d7674bb47ec1c58681ce56c49ba9e4d",
                        ClientVersionCheckResult.Latest
                    )
                )
            ),
            ExampleObject(
                ClientVersionCheckResult.ForceUpdate.name, ClientVersionCheckResult.ForceUpdate.name, "必须先更新版本才能继续使用",
                DataResponseDTO(
                    AppLoginResponse(
                        "club:android:user:421feef3-c1b4-4525-a416-6a11cf6ed9ca:2d7674bb47ec1c58681ce56c49ba9e4d",
                        ClientVersionCheckResult.ForceUpdate
                    )
                )
            )
        )
    }
    
    val Logout = OpenApiOperation("Logout", listOf(AuthTag))
    
    val SendNotification = OpenApiOperation("SendNotification", listOf(UserTag)) {
        addRequestExample(
            SendNotificationForm(
                recipients = mutableSetOf(Recipient("[email protected]", name = "tester", email = "[email protected]")),
                userFilters = mapOf(ClubUserType.User.value to "[account = [email protected]]"),
                content = NotificationContent(
                    email = mutableMapOf(Lang.zh_TW to EmailContent("Test Email", "This is a test")),
                    push = mutableMapOf(Lang.zh_TW to PushContent("Test Push", "This is a test")),
                    sms = mutableMapOf(Lang.zh_TW to SMSContent("Test SMS"))
                ),
                contentArgs = mutableMapOf("data" to "test")
            )
        )
    }
}

透过编程自动产生 OpenAPI Definition

根据工程师懒得写文件的天性,我希望能自动产生更多的 definition。例如 OpenAPI Spec 本身没有定义 API Authoriation,所以不会有对应的 definition,因为这是属於应用程序自己的商业逻辑范围。但实务上,client 端串接 API 时会想透过 API 文件知道每一个 API 的角色权限,所以後端工程师必须要为每一个 API 在 summary definition 写角色权限描述文字。

其它需求还有… 如果这个 API 需要使用者登入後才能呼叫,那麽必须要填入 sessionId header;如果 API 呼叫端是 App,那必须填入 clientVersion header 检查版本是否要升级。这些 definition 如果能自动产生,就可以降低写文件的时间,也不会发生漏写情形。

private fun bindAuth(routeAuths: List<PrincipalAuth>?) {
    // 自动加上 PrincipalAuth 资讯於 summary 栏位
    operationObject.summary += " => Auth = [${routeAuths?.joinToString(" or ") ?: "Public"}]"
    if (routeAuths != null) {
        setOperationSecurities(routeAuths)
        setSessionIdHeader(routeAuths)
        setClientVersionHeader(routeAuths)
    }
}

// 设定 Authentication 的 security definition
private fun setOperationSecurities(routeAuths: List<PrincipalAuth>) {
    val securitySchemes = routeAuths.map { routeAuth ->
        routeAuth.securitySchemes.map { it.createSecurity() }
    }.filter { it.isNotEmpty() }.toSet()
    if (securitySchemes.isNotEmpty()) {
        operationObject.security = securitySchemes.toList()
    }
}

// 自动加上 sessionId header
private fun setSessionIdHeader(routeAuths: List<PrincipalAuth>) {
    if (routeAuths.all { it is PrincipalAuth.User }) {
        operationObject.parameters += BuiltinComponents.SessionIdHeader
    } else if (routeAuths.any { it is PrincipalAuth.User }) {
        operationObject.parameters += BuiltinComponents.SessionIdOptionalHeader
    }
}

// 自动加上 clientVersion header
private fun setClientVersionHeader(routeAuths: List<PrincipalAuth>) {
    if (routeAuths.flatMap { it.allowSources }.any { it.login && it.type.isApp() }) {
        operationObject.parameters += BuiltinComponents.ClientVersionOptionalHeader
    }
}

例如 Club 子专案的变更密码 API,只要是 user 都可以变更,所以 summary 自动加上 admin 及 member 所有使用者角色。除此之外,因为是「可以」由 App 端,而且「必须」在登入後才能呼叫,所以会自动加上 header => sid (required) 及 clientVersion (optional)

route("${ClubConst.urlRootPath}/users") {
    authorize(ClubAuth.User) {

        put<UpdateUserPasswordForm, Unit>("/myPassword", ClubOpenApi.UpdateMyPassword) { form ->
            val userId = call.principal<UserPrincipal>()!!.userId
            clubUserService.updatePassword(userId, form)
            call.respond(CodeResponseDTO.OK)
        }
    }
}

至於 Ops 子专案的 API 文件,因为不存在 App 端,所以就没有 clientVersion header

今天说明我是如何以编程方式撰写 API 文件,还有整合框架自动产生更多 definition,明天就要进入底层 OpenAPI Generator 的实作,说明如何 serialize definition 物件为 openapi.json


<<:  D6 - 你不知道 Combo : 主菜 Scope 字汇环境

>>:  day6: CSS style 规划 - CSS in js

风险的决策应在投资评估过程中行使

-什麽是风险? 选项B提供了最佳视角,但正确的版本应为“剩余风险低於董事会的风险承受能力。” 基於...

Day 06 : 什麽是 MLOps

各种商务情境都在思考如何融入 AI 提供更适切的智慧化服务,在Day 04 : 以资料为中心的人工智...

Day 14:Coroutine,那是什麽?好吃嘛?

Keyword: coroutine 这几天在使用网路功能时,都使用到了Kotlin的Corouti...

[ Day:29 ] GitHub Actions 懒人部署 - 如何安装多个来源的 npm package

情境,有一包公司内部的private-pkg,要安装才能 Build ,怎麽办QAQ 建立 .npm...

@Day16 | C# WixToolset + WPF 帅到不行的安装包 [更改打包语言]

虽然我们在Product 上面有看到Language = "1032" 的语法,...