
Ktor 這邊跟 OpenAPI 有關的東西不只一樣,版本目錄裡有 5 個 artifact,另外還有一個 Gradle 的 compiler plugin,這篇從什麼描述都還沒寫的第 1 版開始,一路寫到每條路由都描述完,中間順便看那幾樣東西各自貢獻了什麼,最後 build.gradle.kts 又剩下什麼
過程中拿掉的東西比留下來的多,下面照順序講
securitySchemes
ktor { openApi { } } 那 3 個開關這一輪加不加沒有差別,因為 code inference 要 Kotlin 2.4.0,這個專案是 2.2.20,整段被跳過describe { } 是 @ExperimentalKtorApi,從原始碼看它的形狀describeJson helper,把 8 條路由重複的錯誤回應寫一次authorize 對 generator 是隱形的,403 要自己補created_at 掉了 format: date-time,那是 day 13 那個自訂 serializer 的必然結果OpenApiDocSource.Routing
/boom 也會被撈進規格,要用 hide() 藏起來/swagger 真正的 UI 從 unpkg.com 抓先看有什麼可以選,ktor-version-catalog-3.5.2.toml 裡跟 OpenAPI 有關的條目,這一輪撈出來是 5 個
openapiSchema = {group = "io.ktor", name = "ktor-openapi-schema", version.ref = "ktor" }
openapiSchema-reflect = {group = "io.ktor", name = "ktor-openapi-schema-reflect", version.ref = "ktor" }
server-openapi = {group = "io.ktor", name = "ktor-server-openapi", version.ref = "ktor" }
server-routingOpenapi = {group = "io.ktor", name = "ktor-server-routing-openapi", version.ref = "ktor" }
server-swagger = {group = "io.ktor", name = "ktor-server-swagger", version.ref = "ktor" }
5 個名字裡面,server-openapi 跟 server-swagger 是 2 條路由,一條給靜態的 HTML 文件、一條給 Swagger UI,server-routingOpenapi 是 describe { } 住的地方,後面會把原始碼翻出來看
要注意的是最後那份 build.gradle.kts 只宣告了 ktor-server-swagger 1 個,而 OpenApi.kt 裡 import io.ktor.server.routing.openapi.describe 編譯得過,也就是 ktor-server-routing-openapi 是被 ktor-server-swagger 帶進來的傳遞相依,不用自己寫
第 1 版照文件走,先把東西全部裝上去再說
build.gradle.kts 的 plugins { } 加一行
id("io.ktor.plugin") version "3.5.2"
dependencies { } 那邊加 2 個
implementation(ktorLibs.server.openapi)
implementation(ktorLibs.server.swagger)
src/main/kotlin/com/cashwu/todo/Application.kt 的 routing { } 裡加 2 行
routing {
openAPI(path = "openapi")
swaggerUI(path = "swagger")
// ...
}
起 server 之後 /swagger 那頁的 script 指向 /swagger/documentation.yaml,那個路徑就是規格本身,這一輪拿到的規格全文只有 63 行,前面這一段逐字是
openapi: 3.1.1
info:
title: Untitled API
version: 1.0.0
paths:
/:
get: {}
/login:
post: {}
/refresh:
post: {}
/me:
get:
summary: ""
security:
- todo-jwt: []
/todos:
post:
summary: ""
security:
- todo-jwt: []
get:
summary: ""
security:
- todo-jwt: []
/todos/{id}:
put:
summary: ""
parameters:
- name: id
in: path
required: true
schema:
type: string
security:
- todo-jwt: []
delete:
summary: ""
parameters:
- name: id
in: path
required: true
schema:
type: string
security:
- todo-jwt: []
get:
summary: ""
parameters:
- name: id
in: path
required: true
schema:
type: string
security:
- todo-jwt: []
結尾這一段
webhooks: {}
components:
securitySchemes:
todo-jwt:
scheme: bearer
bearerFormat: JWT
description: JWT Bearer Authentication
type: http
相關路徑上的 9 個 operation 全部被找到,{id} 被認出來是 path parameter,而 day 26 那個 authenticate(TODO_AUTH) 直接變成了 security: - todo-jwt: [],securitySchemes.todo-jwt 底下的 scheme: bearer 跟 bearerFormat: JWT 也不是手寫的,那是從 day 27 那個 jwt(TODO_AUTH) provider 推出來的
這一段沒有多寫一行,是 day 26 到 day 28 那些宣告順便換來的,認證那 3 篇當時在意的是 401 跟 403 的邊界,沒有人想過那些宣告會變成文件,可是路由樹上本來就存著路徑、方法、path parameter 跟「這條掛在哪個 provider 底下」,把它走一次就得到半份規格
缺的是 3 件事,title 是 Untitled API、每個 operation 的 summary 是空字串、完全沒有 request 跟 response 的 body,剩下的篇幅都是在補這 3 樣
文件講的設定區塊是這樣,寫在 build.gradle.kts 的頂層
ktor {
openApi {
enabled = true
codeInferenceEnabled = true
onlyCommented = false
}
}
加上去之後重新產生規格,行數一樣是 63 行,diff 完全沒有輸出,3 個值本來就是預設值,寫不寫都一樣
註解那條路也試過,KDoc 區塊註解跟 // 行註解各一次,掛在 get("/me") 上面,2 次的規格裡 /me 那一段都還是
/me:
get:
summary: ""
security:
- todo-jwt: []
原因不在寫法,./gradlew 在 configure 階段就已經把話講清楚了
warning: Ktor OpenAPI inference is enabled but requires Kotlin 2.4.0 or higher (found 2.2.20). OpenAPI inference will be skipped.
codeInferenceEnabled 要 Kotlin 2.4.0 才動得起來,這個專案是 2.2.20,整個推論被跳過,所以那 3 個開關怎麼調都不會有差別,註解也不可能變成 summary,這是一個 warning 不是 error,build 照樣成功,不特別去看不會發現
註解那條路沒走通,換一條,直接用程式碼描述,ktor-server-routing-openapi 的 DescribeRoute.kt 整個檔案就這些,KDoc 拿掉之後是
public val OperationDescribeAttributeKey: AttributeKey<List<RouteOperationFunction>> =
AttributeKey("OperationDescribe")
public val OperationHiddenAttributeKey: AttributeKey<Unit> =
AttributeKey("OperationHidden")
public val JsonSchemaAttributeKey: AttributeKey<JsonSchemaInference> =
AttributeKey("JsonSchemaInference")
public typealias RouteOperationFunction = Operation.Builder.() -> Unit
@ExperimentalKtorApi
public fun Route.describe(configure: RouteOperationFunction): Route {
attributes.remove(OperationHiddenAttributeKey)
attributes[OperationDescribeAttributeKey] =
when (val previous = attributes.getOrNull(OperationDescribeAttributeKey)) {
null -> listOf(configure)
else -> previous + configure
}
return this
}
@ExperimentalKtorApi
public fun Route.hide(): Route {
attributes[OperationHiddenAttributeKey] = Unit
return this
}
2 個公開的函式,describe 跟 hide,2 個都接收一個 Route 回一個 Route,回傳型別是關鍵,這代表它們可以直接接在 get { }、post { } 後面,寫成 get { ... }.describe { ... },不用另外開一層 route 節點
3 個 AttributeKey 說明了它的做法,描述被存成 route 的 attribute,Swagger UI 那條路由在被打的時候才去讀,全部都在 runtime,describe 那個 when 還說了一件事,同一條路由描述 2 次不會蓋掉前一次,是把 lambda 接在後面,而且它會先把 hidden 那個 key 移掉
2 個函式都掛著 @ExperimentalKtorApi,所以用它要 @OptIn(ExperimentalKtorApi::class),API 形狀之後版本可能會改
Operation.Builder 不在這個 artifact 裡,它在 ktor-openapi-schema,那是 swagger 一起帶進來的另一個傳遞相依,它在 Operation.kt,這篇會用到的節錄下來是
@KtorDsl
public class Builder(
private val schemaInference: JsonSchemaInference,
private val defaultContentTypes: List<ContentType>,
) : JsonSchemaInference by schemaInference {
public var summary: String? = ""
public var description: String? = null
public fun parameters(configure: Parameters.Builder.() -> Unit) { ... }
public fun requestBody(configure: RequestBody.Builder.() -> Unit) { ... }
public fun responses(configure: Responses.Builder.() -> Unit) { ... }
public fun security(configure: Security.Builder.() -> Unit) { ... }
}
大致對得上 OpenAPI 的 operation 物件,buildSchema 不在這個類別上,是委派來的,JsonSchemaInference 這個 fun interface 全文就 1 行
public fun interface JsonSchemaInference {
public fun buildSchema(type: KType): JsonSchema
}
收一個 KType 回一個 JsonSchema,也就是型別要自己傳進去,寫的時候是 buildSchema(typeOf<Todo>())
還有一個形狀要先知道,Responses.Builder 跟 Response.Builder 的入口是 operator invoke
public fun response(statusCode: Int, configure: Response.Builder.() -> Unit) { ... }
public operator fun HttpStatusCode.invoke(configure: Response.Builder.() -> Unit = {}) {
response(value, configure)
}
response() 那個函式收的是數字不是 HttpStatusCode,那個 operator invoke 是掛在 HttpStatusCode 上的擴充,把 value 轉進去,所以在那 2 層裡面寫的是 HttpStatusCode.OK { } 跟 ContentType.Application.Json { },狀態碼跟 content type 自己就是那個函式
title 跟 version 不寫死,src/main/resources/application.yaml 尾巴加 3 行,跟 auth 同一層
auth:
// ...
+ openapi:
+ title: "$OPENAPI_TITLE:Todo API"
+ version: "$OPENAPI_VERSION:1.0.0"
照 day 27 定的那條規矩,不是秘密的就給預設值,環境變數沒設也起得來
src/main/kotlin/com/cashwu/todo/TodoConfig.kt 那邊 TodoConfig 多一個 openapi 欄位,加上一個新的 data class
@Serializable
data class TodoConfig(
// ...
val openapi: OpenApiConfig,
)
@Serializable
data class OpenApiConfig(
val title: String,
val version: String,
)
改到這裡先跑一次 ./gradlew test,會停在編譯
e: ConfigurationTest.kt:42:13 No value passed for parameter 'openapi'.
那個測試是把整份設定建一個期望值出來整包比對,TodoConfig 多一個欄位它就編不過,這正是整包比對的用處,漏掉的東西會在編譯期就被擋下來,不是等到某個斷言失敗才發現
在 ConfigurationTest 的 the todo node maps onto the data class 測試補上新的期望值
auth = AuthConfig(
// ...
),
+ openapi = OpenApiConfig(
+ title = "Todo API",
+ version = "1.0.0",
+ ),
另外,名字是 OpenApiConfig 而不是 OpenAPIConfig 是刻意的,ktor-server-openapi 裡面有一個 io.ktor.server.plugins.openapi.OpenAPIConfig,那是 openAPI(path) { } 那個區塊的接收者,身上掛的是 source、outputPath、info 這些欄位,跟這裡要的 title 跟 version 完全是兩回事,2 個在不同的 package,同名也不會編譯失敗,可是讀的人跟 IDE 的補完清單會分不出來,差一個大小寫就避開了
接著是新檔案 src/main/kotlin/com/cashwu/todo/OpenApi.kt,全文是這樣
package com.cashwu.todo
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.openapi.OpenApiInfo
import io.ktor.server.plugins.swagger.swaggerUI
import io.ktor.server.routing.Route
import io.ktor.server.routing.openapi.describe
import io.ktor.utils.io.ExperimentalKtorApi
import kotlin.reflect.KType
import kotlin.reflect.typeOf
const val SWAGGER_PATH = "swagger"
const val SPEC_PATH = "$SWAGGER_PATH/documentation.yaml"
fun Route.docsRoutes(config: OpenApiConfig) {
swaggerUI(path = SWAGGER_PATH) {
info = OpenApiInfo(title = config.title, version = config.version)
}
}
@OptIn(ExperimentalKtorApi::class)
fun Route.describeJson(
summary: String,
successStatus: HttpStatusCode = HttpStatusCode.OK,
responseType: KType? = null,
requestType: KType? = null,
secured: Boolean = true,
requiredRole: String? = null,
badRequest: Boolean = false,
notFound: Boolean = false,
): Route = describe {
this.summary = summary
if (requestType != null) {
requestBody {
required = true
ContentType.Application.Json { schema = buildSchema(requestType) }
}
}
responses {
successStatus {
description = summary
if (responseType != null) {
ContentType.Application.Json { schema = buildSchema(responseType) }
}
}
if (badRequest) {
HttpStatusCode.BadRequest {
description = "請求內容不正確"
ContentType.Application.Json { schema = buildSchema(typeOf<ErrorResponse>()) }
}
}
if (notFound) {
HttpStatusCode.NotFound {
description = "找不到指定的資源"
ContentType.Application.Json { schema = buildSchema(typeOf<ErrorResponse>()) }
}
}
if (secured) {
HttpStatusCode.Unauthorized {
description = "沒有帶 token,或是 token 不能用"
ContentType.Application.Json { schema = buildSchema(typeOf<ErrorResponse>()) }
}
}
if (requiredRole != null) {
HttpStatusCode.Forbidden {
description = "身分有效,但沒有 $requiredRole 這個角色"
ContentType.Application.Json { schema = buildSchema(typeOf<ErrorResponse>()) }
}
}
}
}
分兩塊看
docsRoutes 只做一件事,把 Swagger UI 裝在 SWAGGER_PATH 上,順便把設定檔那 2 個值帶進 info,它不用 @OptIn,swaggerUI 跟 SwaggerConfig 都不是 experimental 的,這個檔案裡只有 describeJson 那半邊要 opt-in
describeJson 是一個包過的 helper,不是原生的東西,理由是 8 條路由的錯誤回應長得一模一樣,day 15 定的 ErrorResponse 就是那個形狀,一條一條用 describe { } 寫會把同樣的錯誤回應重複 8 次,包成一個函式之後,每條路由要提供的只剩下「這條在做什麼」跟「進出的型別是什麼」
successStatus 給了預設值 HttpStatusCode.OK,因為只有新增是 201、刪除是 204,secured 預設是 true,只有 /login 跟 /refresh 要關掉,那 2 條本來就是拿 ticket 的地方,400 跟 404 則預設不出現,由每條路由依實際行為打開,/me 不會因為共用 helper 就憑空多一個 400,查不到指定 id 的路由也會把 404 寫進文件
最後的對應很直接,/login、/refresh、新增待辦跟待辦清單都會描述 400,清單那條是因為 day 22 的 limit 只收 0 以上的整數,打壞了就是 400,/me 身上沒有任何參數可以打壞,所以 400 跟 404 都不加,帶 {id} 的查詢、更新與刪除則同時描述 400 與 404,共用的是錯誤 body 的 schema,不是每條路由都可能發生同一組錯誤
接線在 src/main/kotlin/com/cashwu/todo/Application.kt,routing { } 裡加一行,同時把第 1 版那 2 行刪掉
routing {
- openAPI(path = "openapi")
- swaggerUI(path = "swagger")
get("/") {
call.respondText("Hello, Ktor!")
}
+ docsRoutes(config.openapi)
tokenRoutes(directory, issuer)
}
那 2 行不刪除會有問題,openAPI 那條是 ktor-server-openapi 的,後面整個相依都要拿掉,留著就編不過,swaggerUI 那條更難察覺,它跟 docsRoutes 裡面的 swaggerUI(path = SWAGGER_PATH) 是同一個 path,先註冊的那條會贏,info = OpenApiInfo(...) 根本沒機會生效,規格的 title 會一直停在 Untitled API,而且什麼錯誤訊息都不會有
路由那邊就是一條一條接上去
src/main/kotlin/com/cashwu/todo/Auth.kt 的 /me
fun Route.meRoute() {
get("/me") {
// ...
}.describeJson(
summary = "看目前這張 token 是誰",
responseType = typeOf<TodoUser>()
)
}
同一個檔案的 /login
post("/login") {
// ...
}.describeJson(
summary = "用帳號密碼換一組 token",
responseType = typeOf<TokenResponse>(),
requestType = typeOf<LoginRequest>(),
secured = false,
badRequest = true,
)
同一個檔案的 /refresh
post("/refresh") {
// ...
}.describeJson(
summary = "用 refresh token 換一組新的",
responseType = typeOf<TokenResponse>(),
requestType = typeOf<RefreshRequest>(),
secured = false,
badRequest = true,
)
/refresh 3 種失敗都是 400,token 過期、拿的是 access token 而不是 refresh token、使用者已經不在了,day 27 那篇把它們全部收在同一個狀態碼底下,所以這裡也只描述 1 個 400
src/main/kotlin/com/cashwu/todo/TodoRoutes.kt 那邊 4 條,新增這條是唯一的 201
post {
// ...
}.describeJson(
summary = "新增一筆待辦",
successStatus = HttpStatusCode.Created,
responseType = typeOf<Todo>(),
requestType = typeOf<CreateTodoRequest>(),
badRequest = true,
)
清單那條回的是一個陣列,responseType 給的是 typeOf<List<Todo>>()
get {
// ...
}.describeJson(
summary = "拿待辦清單",
responseType = typeOf<List<Todo>>(),
badRequest = true,
)
那個 badRequest = true 是 day 22 的 limit 逼出來的,它只收 0 以上的整數,其他都是 400
查一筆的那條多一個 404
get("{id}") {
// ...
}.describeJson(
summary = "看一筆待辦",
responseType = typeOf<Todo>(),
badRequest = true,
notFound = true,
)
更新那條有進也有出
put("{id}") {
// ...
}.describeJson(
summary = "改一筆待辦",
responseType = typeOf<Todo>(),
requestType = typeOf<UpdateTodoRequest>(),
badRequest = true,
notFound = true,
)
規格產生器是走整棵 routing tree,所以前面幾天為了觀察行為留下的路由也會一起被撈出來。get("/boom") 就是一條,它專門丟例外拿來看 500 長什麼樣子,CallLoggingTest 跟 StatusPagesTest 都靠它,程式碼刪不得,可是它不該出現在給人看的 API 文件裡
hide() 就是給這種東西用的
get("/boom") { throw RuntimeException("資料庫密碼是 hunter2") }.hide()
加上去之後 paths 從 7 條回到 6 條,它跟 describe { } 一樣掛著 @ExperimentalKtorApi,所以 Application.kt 也要掛 @OptIn(ExperimentalKtorApi::class)
要注意的是 Swagger UI 自己那條路由不用管,docsRoutes 裝出來的 /swagger 從第 1 版起就沒有出現在 paths 底下,generator 本來就會把它排除掉。真正需要 hide() 的是像 /boom 這種寫在自己 routing { } 裡的一般路由
同一個檔案裡的刪除那條,多帶一個參數
route("{id}") {
authorize(ADMIN_ROLE) {
delete {
// ...
}.describeJson(
summary = "刪掉一筆待辦,只有 admin 可以",
successStatus = HttpStatusCode.NoContent,
requiredRole = ADMIN_ROLE,
badRequest = true,
notFound = true,
)
}
}
requiredRole 這個參數是被逼出來的,理由在 day 28
authenticate 是 Ktor 自己的,所以 security 那一段從第 1 版就是自動的,一個字都沒寫,authorize 不是,那是 day 28 用 createRouteScopedPlugin 自己寫的,generator 不認得它,對規格產生器來說,那個 route 節點上只有一個叫不出名字的 plugin,它看不出「這條會回 403」
這是一條界線,框架自己的宣告會被讀進文件,自己寫的宣告不會,day 28 那篇的 AuthorizationRouteSelector 連 toString 都寫了 (authorize admin),routing 的 trace 上看得到,可是那是給人看的字串,不是給 generator 讀的結構
所以 403 這件事變成 2 個地方要維護,一個是 authorize(ADMIN_ROLE),一個是 requiredRole = ADMIN_ROLE,兩邊用的是同一個常數,改角色名字不會漏,但如果有人把 authorize 拿掉而忘了改描述,規格上那個 403 會留著,沒有東西會擋
開頭
openapi: 3.1.1
info:
title: Todo API
version: 1.0.0
paths:
/:
get: {}
/login:
post:
summary: 用帳號密碼換一組 token
Untitled API 換成了設定檔裡那個 title,/ 那條還是 get: {},因為它沒有被描述,那是 day 02 留下來的 Hello, Ktor
/me 那一段完整長這樣
/me:
get:
summary: 看目前這張 token 是誰
responses:
"200":
description: 看目前這張 token 是誰
content:
application/json:
schema:
$ref: "#/components/schemas/TodoUser"
"401":
description: 沒有帶 token,或是 token 不能用
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
security:
- todo-jwt: []
security 那 2 行還是自動的,responses 那一大段是 describeJson 產的,$ref 那個形式代表 schema 沒有被展開寫在每個 operation 裡,而是抽到 components.schemas 底下共用
清單那條的 200 是另一個形狀
"200":
description: 拿待辦清單
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Todo"
typeOf<List<Todo>>() 沒有多生一個 schema 出來,List 就地展開成 type: array,items 再指回 Todo,components.schemas 底下始終只有那 8 個具名的 data class
DELETE /todos/{id} 是 401 跟 403 並排的那一條
delete:
summary: 刪掉一筆待辦,只有 admin 可以
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"204":
description: 刪掉一筆待辦,只有 admin 可以
"400":
description: 請求內容不正確
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: 找不到指定的資源
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"401":
description: 沒有帶 token,或是 token 不能用
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"403":
description: 身分有效,但沒有 admin 這個角色
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
security:
- todo-jwt: []
狀態碼的排法要先講一下,這裡是 400、404、401、403,不是由小到大,規格產生器照的是寫進去的先後,也就是 describeJson 裡 badRequest、notFound、secured、requiredRole 那 4 個 if 的順序,helper 怎麼排,規格就怎麼排
204 底下沒有 content,因為 responseType 沒給,describeJson 那個 if (responseType != null) 就跳過了,204 本來就不該有 body,這一點規格跟實際的回應對得上
parameters 那一段也不用寫,路由上寫的是 route("{id}"),generator 自己認出來那是 path parameter 而且 required: true,型別仍是 type: string,因為數字轉換藏在 day 22 的 call.todoId() 裡,規格產生器看不到,這也說明這份文件目前只到路由層級,還不能稱為完整的 API 契約,要把 id 寫成 integer,得另外補參數描述
components.schemas 底下這一輪生出 8 個,名字是
LoginRequest:
TokenResponse:
ErrorResponse:
RefreshRequest:
TodoUser:
Todo:
UpdateTodoRequest:
CreateTodoRequest:
Todo 那一份長這樣
Todo:
type: object
title: Todo
required:
- id
- title
- created_at
properties:
id:
type: integer
title:
type: string
done:
type: boolean
created_at:
type: string
這裡有 3 件事可以講
第 1,done 不在 required 裡面,day 13 那個 data class 給了它預設值,schema 是從 kotlinx.serialization 的 descriptor 生出來的,「這個欄位有預設值」那件事 descriptor 讀得到,所以它自動變成選填
第 2,欄位名是 created_at 不是 createdAt,那是 day 13 那個 @SerialName 的結果,規格跟實際送出去的 JSON 走的是同一份 descriptor,也就是同一份事實,不會出現文件寫駝峰、回應是底線那種對不上的情況
第 3,created_at 只有 type: string,沒有 format: date-time,day 13 為 Instant 寫的那個自訂 serializer 對外就是一個字串,型別資訊到序列化那一層就斷了,descriptor 上剩下的只有 STRING,這是這篇量到的第 2 個缺口,跟 403 那個一樣要自己補,只是這一個沒補,留著
每條路由都描述完之後,把 id("io.ktor.plugin") version "3.5.2" 跟整個 ktor { openApi { } } 區塊從 build.gradle.kts 拿掉,重新起 server 再抓一次規格,跟拿掉之前的那份 diff,沒有任何輸出,一個 byte 都不差,沒有 Gradle plugin
原因在規格是從哪裡來的,swaggerUI 用的 OpenApiDocSource.Routing 是在 runtime 走 routing tree,路徑、方法、path parameter、authenticate 帶來的 security,全部是那個時候讀出來的,跟編譯期沒有關係,第 1 版那 63 行是它讀出來的,describe { } 存在 route attribute 上的那些描述也是它讀出來的
compiler plugin 要補的是 runtime 讀不到的那一半,也就是 handler 裡面的型別跟原始碼裡的註解,這 2 樣東西在 class file 上已經沒有了,只有編譯器看得到,而這篇是自己用 describe { } 把型別跟描述寫出來的,那一半本來就不缺,plugin 沒有東西可以補
所以這不是「plugin 沒用」,是「這個專案已經用另一種方式把 plugin 要補的東西補完了」,2 條路是替代關係,選了手寫就不需要它,選了它就不用手寫,這一次選手寫的理由是註解那條路沒走通,如果那條路走得通,這篇的 describeJson 大概可以不用存在
順著這個結論,day 02 說的,那篇把 Kotlin 綁在 2.2.20,理由就是這個 extension,官方文件寫的是「requires Kotlin 2.2.20,用別的版本可能會編譯失敗」,而諷刺的地方在這裡,2.2.20 讓 plugin 裝得起來,卻剛好卡在 code inference 要的 2.4.0 以下,所以那個 extension 唯一能貢獻的功能從第 1 天就是關的,現在 plugin 拿掉了,那個綁定還在,而且它從頭到尾沒換到任何東西,規格是 diff 出來一個 byte 都不差的那份,這個綁定也不是沒有代價,day 13 之所以要自己刻 Instant 的 serializer,就是因為 2.2.20 上 stdlib 的 kotlin.time.Instant 還是 experimental,得 opt-in 才能用,所以完整的是這樣,為了一個最後沒留下的 plugin,整個系列用了一個舊的 Kotlin,還連帶多寫了一個 serializer
會做這個決定是因為 day 02 當下不知道 day 29 變成這樣,先對齊比中途升版安全,這個判斷本身不算錯,只是保險沒用上,保費就是純成本,如果現在重來一次,會讓 day 02 用當下的 Kotlin 的版本,等真的走到需要 compiler plugin 那一步再決定要不要為它降版,因為降版永遠來得及,而被舊版本綁住的那些副作用會一路累積
/swagger 回的 HTML 開頭是
<!DOCTYPE html>
<html>
<head>
<title>Swagger UI</title>
<link href="https://unpkg.com/swagger-ui-dist@5.31.0/swagger-ui.css" rel="stylesheet">
<link href="https://unpkg.com/swagger-ui-dist@5.31.0/favicon-32x32.png" rel="icon" type="image/x-icon">
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.31.0/swagger-ui-bundle.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/swagger-ui-dist@5.31.0/swagger-ui-standalone-preset.js" crossorigin="anonymous"></script>
<script>window.onload = function() {
window.ui = SwaggerUIBundle({
url: '/swagger/documentation.yaml',
dom_id: '#swagger-ui',
deepLinking: false,
oauth2RedirectUrl: window.location.origin + '/swagger/oauth2-redirect.html',
真正的 UI 從 unpkg.com 抓 swagger-ui-dist@5.31.0,也就是 server 沒有打包 swagger-ui 那一整包資源,這也是它只帶 6 個 jar 的理由,那 6 個裡面有 ktor-server-html-builder-jvm 跟 kotlinx-html-jvm,就是拿來產這個殼的
這件事在正式環境要先想過,內網環境連不到 unpkg,那頁就是空白,要讓文件頁不相依外部 CDN,SwaggerConfig 有 packageLocation 跟 version 2 個欄位可以換掉來源
還有一件事要決定,規格本身不用 token 就讀得到
docsRoutes 沒有包在 authenticate 裡面,所以 /swagger 跟 /swagger/documentation.yaml 是公開的,對一個公開的 API 來說這是對的,文件本來就要給人看,內部 API 的話這行為要改,把 docsRoutes 放進 authenticate(TODO_AUTH) 就好,只是那樣 Swagger UI 那頁本身也要先有 ticket 才進得去
既有測試只動了一個檔案,ConfigurationTest 那份把整份設定比對一次的期望值多了 4 行,就是新的 OpenApiConfig
這件事本身是一個結果,加文件沒有改到任何行為,describeJson 接在 handler 後面,handler 本身沒有動過一行
新增檔案 src/test/kotlin/com/cashwu/todo/OpenApiTest.kt,先寫 2 個共用的 helper
private suspend fun ApplicationTestBuilder.spec(): String =
client.get("/$SPEC_PATH").bodyAsText()
private fun String.pathNames(): List<String> {
val lines = substringAfter("\npaths:\n").lineSequence()
return lines.takeWhile { it.startsWith(" ") || it.isBlank() }
.filter { it.startsWith(" /") }
.map { it.trim().removeSuffix(":") }
.toList()
}
spec() 就是打那條規格路由把 YAML 拿回來,pathNames() 是一個很陽春的 YAML 解析,從 paths: 那行開始往下拿縮排的行,只留 2 格縮排的那些,那就是路徑,用得起這種寫法是因為它只要負責這一份檔案,為了測試多拉一個 YAML 函式庫的相依不划算
第 1 個驗證的是路徑清單,這裡比對的是 paths 底下的路徑,一條路徑上可以掛好幾個方法,所以數量比路由少
@Test
fun `every route the api serves shows up in the spec`() = testApplication {
todoApplication()
val paths = spec().pathNames()
assertEquals(
listOf("/", "/login", "/refresh", "/me", "/todos", "/todos/{id}"),
paths,
)
}
todoApplication() 是 TestApp.kt 裡那個把整個 app 起起來、回一個已經帶著 TEST_TOKEN 的 client 的 helper
這個測試驗證的是文件最容易壞掉的地方,文件會壞不是因為寫不出來,是寫完之後跟程式碼分岔,這個斷言用的是完整清單比對,新增一條路由或刪掉一條路由而沒有同步描述,它會失敗,它擋不住的是「路由還在但描述過期了」,那件事這一輪沒有辦法自動驗證
第 2 個驗證的是「security 是不用寫的」
@Test
fun `the security scheme comes from the jwt provider`() = testApplication {
todoApplication()
val yaml = spec()
assertContains(yaml, "securitySchemes:")
assertContains(yaml, " $TODO_AUTH:")
assertContains(yaml, "scheme: bearer")
assertContains(yaml, "bearerFormat: JWT")
}
4 個斷言盯的是同一件事,那段 securitySchemes 沒有任何一個字是手寫的,TODO_AUTH 用的是 day 26 那個常數,provider 改名的話這裡會一起失敗
第 3 個驗證的是 403
@Test
fun `the delete operation documents the 403 that authorize can produce`() = testApplication {
todoApplication()
val delete = spec().substringAfter(" delete:").substringBefore(" get:")
assertContains(delete, """"403"""")
assertContains(delete, "身分有效,但沒有 $ADMIN_ROLE 這個角色")
}
這個就是前面那條界線的守門人,authorize 對 generator 是隱形的,所以 403 只能靠 requiredRole 進去,這個測試確認它真的進去了
最後 2 個驗證的是那 2 個 schema 細節
@Test
fun `a field with a default value is not required`() = testApplication {
todoApplication()
val todo = spec().substringAfter(" Todo:").substringBefore(" UpdateTodoRequest:")
assertContains(todo, "- id")
assertContains(todo, "- title")
assertContains(todo, "- created_at")
assertFalse(todo.substringAfter("required:").substringBefore("properties:").contains("- done"))
}
@Test
fun `the custom instant serializer leaves the date format out`() = testApplication {
todoApplication()
val todo = spec().substringAfter(" Todo:").substringBefore(" UpdateTodoRequest:")
val createdAt = todo.substringAfter("created_at:").substringBefore(" UpdateTodoRequest")
assertContains(createdAt, "type: string")
assertFalse(createdAt.contains("format: date-time"))
}
第 2 個測試的名字寫的是現況不是期望,它斷言的是「這裡沒有 format: date-time」,也就是把一個已知的缺口寫成測試,哪天有人替 InstantSerializer 補上 descriptor 的格式資訊,這個測試會失敗,而那個失敗是好消息,改掉斷言就是了,缺口寫成測試的用處是它不會被忘記,也不會有人以為那是設計
Relix 那個系列從頭到尾沒有做 API 文件,day 31 那份「刻意不做的東西」清單裡連 OpenAPI 都沒有出現,所以這篇沒有現成的自問自答可以對,只有一件事值得拿出來比
Relix 的路由表是一個 mutableListOf<Route>(),day 23 收尾的時候那個 data class 是
data class Route(
val method: String,
val path: String,
val requiresAuth: Boolean = false,
val requiredRoles: Set<String> = emptySet(),
val handler: RelixHandler,
)
requiredRoles 就寫在上面,Ktor 的 route 節點沒有這個東西,403 是這篇自己用 requiredRole 參數補的,Relix 那邊只要讀那個欄位就好,手刻框架在這件事上佔便宜,它的路由表是自己的資料結構,想放什麼就放什麼,Ktor 要塞自訂資訊得走 AttributeKey,而那正是 describe { } 在做的事
至於 body 的型別,兩邊一樣看不到,RelixHandler 也是進了 handler 才 receive,換一個框架不會讓 describe { } 這件事變簡單
這篇的規格不是 compiler plugin 產的,swaggerUI 用的 OpenApiDocSource.Routing 在 runtime 走 routing tree,路徑、方法、path parameter 跟 authenticate 帶來的 security 都是那個時候讀出來的,所以什麼描述都還沒寫的第 1 版就已經有規格了,那一段沒有多寫一行,是 day 26 到 day 28 那些宣告順便換來的,缺的只有 title、summary 跟 request 與 response body
ktor { openApi { } } 那 3 個開關加上去 diff 沒有輸出,讓註解變成 summary 也沒進規格,原因在 configure 階段那行 warning,code inference 要 Kotlin 2.4.0,這個專案從 day 02 就綁在 2.2.20,整段被跳過了
所以描述是自己用 describe { } 寫的,包成一個 describeJson helper,這裡到到的那條界線最值得記下來,authenticate 是 Ktor 自己的,security 自動就有,authorize 是 day 28 用 createRouteScopedPlugin 寫的,generator 不認得,403 只能自己補,框架自己的宣告會被讀進文件,自己寫的不會,/boom 那種除錯路由也一樣要自己 hide() 掉
最後留下來的比拿掉的少,Gradle plugin 跟 ktor { openApi { } } 移除之後規格 diff 一個 byte 都不差,ktor-server-openapi 也拿掉了,build.gradle.kts 淨增的是 ktor-server-swagger 一行
這篇留下的問題有 5 筆,created_at 少一個 format: date-time,那要動的是 day 13 那個 serializer 的 descriptor,不是文件這一層,describe { } 跟 hide() 都是 @ExperimentalKtorApi,Ktor 之後版本改形狀的話要跟著改,每條路由的描述要手寫,路由改了規格不會自己跟上,OpenApiTest 那份完整路徑清單擋得住新增或刪除路由沒同步,擋不住描述過期,Swagger UI 從 unpkg.com 抓 5.31.0,正式環境要不要換成專案自己打包的資源這篇沒有決定,最後是 compiler plugin 那條路,讓原始碼註解自動變成 summary 要 Kotlin 2.4.0,那才是真正能讓文件跟程式碼綁在一起的做法
下一篇換一個完全不同的模型,到這裡的每一條路由都是「一個請求換一個回應」,day 30 做 WebSocket,連線開著,兩邊都可以主動送東西
同步刊登於 Blog
圖片來源:AI 產生