iT邦幫忙

2021 iThome 鐵人賽

DAY 16
0
Modern Web

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

[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(
                "tester@test.com", "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("tester@test.com", name = "tester", email = "tester@test.com")),
                userFilters = mapOf(ClubUserType.User.value to "[account = tester@test.com]"),
                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


上一篇
[Day 15] 實作 OpenAPI Plugin 產生 API 文件
下一篇
[Day 17] 實作 Ktor OpenAPI Generator
系列文
基於 Kotlin Ktor 建構支援模組化開發的 Web 框架30

尚未有邦友留言

立即登入留言