iT邦幫忙

2026 iThome 鐵人賽

0
Software Development

Kotlin Ktor 實戰 101系列 第 32 篇

Kotlin Ktor 實戰 101 Day 32 htmx 與同一個 API 的第二種表現形式

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260909/20121948LfQo3aVCtG.jpg

前面已經把這個 API 一路長到能認證、能授權、能推播、能去外部服務把資料匯進來,但它從頭到尾只服務機器 (API)

這篇讓它多長出一種給人看的表現形式,用 htmx 接起來,同一個 /todos 帶 HX-Request 就回 HTML fragment、不帶還是回 JSON,而把瀏覽器接上去這件事,會讓前面做過的認證去做一件它從來沒被要求過的事

這篇要完成什麼

  • 加上 4 行 htmx 相關的相依,其中 HTML 渲染跟 session 這 2 個早就被 day 29 跟 day 26 帶進 classpath 了
  • hx { } 讓 /todos 這一個 URL 服務 2 種客戶端,帶 HX-Request 回 HTML fragment,不帶回 JSON
  • 瀏覽器導覽送不出 Authorization header,WWW-Authenticate: Bearer 也不會讓它跳帳密框,這是 day 26 選 Bearer 的時候沒有考慮的客戶端
  • 照文件最短寫法裝出來的 cookie session 是明文而且沒有簽章,可以手工偽造,transform 那一段不是預設值
  • authenticate(SESSION_AUTH, TODO_AUTH) 使用預設策略時,session 的 challenge 先完成回應,原本斷言 401 的測試整批變成 302
  • 修完還有失敗,而那幾個 class 隔離跑都會通過,整套測試共用 jdbc:h2:mem:todo 這一個名字,坑從 day 20 就埋在那裡,而修掉它又當場生出新的失敗
  • OpenAPI 規格上 HX-Request 出現 0 次,不是產生器看不到那個分支,是同一格放不下 2 個 operation
  • 整套測試最後全部通過

htmx 是什麼

htmx 是一個 JavaScript 函式庫,但用它的人幾乎不寫 JavaScript,它做的事情是讓 HTML 的標籤自己能發 HTTP 請求,然後把回來的東西當成 HTML,直接換掉頁面上的某一塊

以這篇那個新增待辦的表單為例,hx-post="/todos" 是送到哪裡,hx-target="#todo-list" 是要換掉誰,hx-swap="outerHTML" 是怎麼換,整條路徑上沒有 JSON,也沒有一份前端自己維護的狀態,server 回什麼,畫面就長什麼樣

它送出去的請求會多帶一個 HX-Request: true 的 header,那是 server 分辨得出「現在是誰在問」的依據,也是後面 hx { } 這個路由條件在看的東西

會在這個系列用它,是因為前面已經有一整套 JSON API 跟一整套授權規則了,給人看的那一面如果另外開一個前端專案,等於再維護一份路由、一份狀態、一份認證流程,而 htmx 這條路是同一批路由多回一種表現形式,多出來的工作只有「產生 HTML」這件事

代價也很清楚,每一次互動都要往 server 跑一趟拿 HTML 回來,而拖拉、即時驗證、離線這一類的互動它沒有辦法處理,那些還是得寫 JavaScript,它適合的是以清單跟表單為主的畫面,這個 Todo 剛好就是

相依

build.gradle.kts 的 dependencies { } 裡加 4 行


dependencies {

     // ...

+    implementation(ktorLibs.server.htmlBuilder)
+    implementation(ktorLibs.server.htmx)
+    implementation(ktorLibs.server.sessions)
+    implementation(ktorLibs.htmx.html)

     // ...
}

這 4 個裡面有 2 個其實早就在 classpath 上了,kotlinx-html 跟 ktor-server-html-builder 是 day 29 的 swagger 帶進來的

帶進來的是: io.ktor:ktor-server-swagger:3.5.2

ktor-server-sessions 是 day 26 的 auth 帶進來的

帶進來的是: io.ktor:ktor-server-auth:3.5.2

也就是說這個專案從 day 26 起就一直帶著 session,從 day 29 起就一直帶著整套 HTML 渲染,只是一直沒有用過

這 2 筆底下是同一個機制,傳遞相依會把你沒有宣告過的東西放進最後的 image 裡,而 build.gradle.kts 上那份清單看不出來,要知道包裡到底有什麼,只能去問 runtimeClasspath

ktor-htmx 這 3 個是 Ktor 3.x 才有的官方支援,不是社群套件,ktor-server-htmx 裡面只有 6 個 class

同一個 URL 兩種回應

新增一個檔案 src/main/kotlin/com/cashwu/todo/TodoHtml.kt,fragment 路由寫在裡面

@OptIn(ExperimentalKtorApi::class)
fun Route.todoFragments(repository: TodoRepository, events: TodoEvents) {
    hx {
        get {
            call.respondText(
                todoListFragment(repository.findAll(null)),
                ContentType.Text.Html,
            )
        }
        post {
            val title = call.receiveParameters()["title"].orEmpty()
            val todo = repository.create(title, false)
            events.publish(TodoEvent.Created(todo))
            call.respondText(
                todoListFragment(repository.findAll(null)),
                ContentType.Text.Html,
                HttpStatusCode.Created,
            )
        }
    }
}

todoListFragment 是同一個檔案裡負責產生那段 HTML 的函式,用 kotlinx.html 的 createHTML() 組出來

fun UL.todoItems(todos: List<Todo>) {
    todos.forEach { todo ->
        li {
            id = "todo-${todo.id}"
            +"${if (todo.done) "[x]" else "[ ]"} ${todo.title}"
        }
    }
}

fun todoListFragment(todos: List<Todo>): String = createHTML().ul {
    id = "todo-list"
    todoItems(todos)
}

createHTML() 回的是一個 String,所以 respondText 收得下。裡面那一圈迴圈另外拆成 UL.todoItems,是因為等一下整頁那一版要用的是同一份清單,<li> 長什麼樣只能有一個地方說了算

hx { } 是 ktor-server-htmx 給的,它依 HX-Request 這個 header 決定要不要接手,跟 day 29 的 describe { } 跟 hide() 一樣掛著 @ExperimentalKtorApi,用它要 @OptIn

它掛在 src/main/kotlin/com/cashwu/todo/TodoRoutes.kt 既有的 route("/todos") 最前面,只加一行

fun Route.todoRoutes(repository: TodoRepository, events: TodoEvents) {
     route("/todos") {
+        todoFragments(repository, events)
         get {
            // ...
         }

         // ...
     }
}

是加進既有的那個 route("/todos") 裡,不是另外開一個同名的

起 server 打打看,資料庫還是 day 23 那個 docker compose 起的 PostgreSQL

docker compose up -d
JWT_SECRET=local-dev-secret-32-bytes-minimum ALICE_PASSWORD=alice-secret BOB_PASSWORD=bob-secret \
DB_PASSWORD=todo ./gradlew run

這一節的請求都要帶 token,所以先登入一次把 access token 存進 shell 變數

curl -s -X POST localhost:8080/login \
  -H 'Content-Type: application/json' \
  -d '{"name":"alice","password":"alice-secret"}' > /tmp/login.json
AT=$(python3 -c 'import json; print(json.load(open("/tmp/login.json"))["accessToken"])')

實際打同一個 URL,帶 HX-Request

curl -s -H "Authorization: Bearer $AT" -H 'HX-Request: true' localhost:8080/todos
<ul id="todo-list">
  <li id="todo-1">[x] 買牛奶</li>
  <li id="todo-2">[ ] 繳電費</li>
  <li id="todo-3">[ ] 寫 day 05 的文章</li>
</ul>

不帶

curl -s -H "Authorization: Bearer $AT" localhost:8080/todos
[{"id":1,"title":"買牛奶","done":true,"created_at":"2026-08-27T08:00:00Z"},{"id":2,"title":"繳電費","done":false,"created_at":"2026-08-28T09:30:00Z"},{"id":3,"title":"寫 day 05 的文章","done":false,"created_at":"2026-08-29T21:15:00Z"}]

同一條路由、同一份資料,2 種表現形式,回 JSON 的那 2 個 handler 一個字都沒動,路由樹上多的是一個掛條件的分支

產生 HTML 的那一側

fragment 是給已經開著頁面的人換掉那段 <ul> 用的,第 1 次進來的人要拿到的是一整頁 HTML,同一個檔案裡的 todoUiPage 就是那一頁,用 respondHtml 加 kotlinx.html 的 DSL 組出來

const val HTMX_SRC = "https://unpkg.com/htmx.org@2.0.4"

@OptIn(ExperimentalKtorApi::class)
fun Route.todoUiPage(repository: TodoRepository) {
    get(UI_PATH) {
        val todos = repository.findAll(null)
        call.respondHtml {
            head {
                title { +"Todo" }
                script(src = HTMX_SRC) {}
            }
            body {
                h1 { +"Todo" }
                ul {
                    id = "todo-list"
                    todoItems(todos)
                }
                form {
                    attributes.hx {
                        post = "/todos"
                        target = "#todo-list"
                        swap = "outerHTML"
                    }
                    input(type = InputType.text, name = "title")
                    button(type = ButtonType.submit) { +"新增" }
                }
            }
        }
    }
}

respondHtml 是 ktor-server-html-builder 給的,它收一個從 <html> 開始的 block,head、body、h1 這些都是 kotlinx.html 的 DSL,回應的 Content-Type 跟 <!DOCTYPE html> 那一行由它自己補

UI_PATH 是 /ui 這個字串常數,跟 day 26 那幾個常數一起放在 src/main/kotlin/com/cashwu/todo/Auth.kt,測試那邊也要用同一個

ul { } 那一段跟 todoListFragment 產生的是同一塊 HTML,靠的就是前面拆出來的 todoItems。id 兩邊必須一模一樣,因為表單那個 hx-target 指的就是它,htmx 換掉的也是它

attributes.hx { } 是 ktor-htmx-html 給的,post、target、swap 這些 property 會變成 hx- 開頭的屬性,實際產出

    <form hx-post="/todos" hx-target="#todo-list" hx-swap="outerHTML"><input type="text" name="title"><button type="submit">新增</button></form>

好處不是少打幾個字,是這 3 個屬性名編譯期就檢查過了,hx-swap 打成 hx-swpa 這種錯,在 HTML 字串裡要等到瀏覽器裡沒反應才會發現。至於 input 跟 button 那 2 行就是 kotlinx.html 一般的用法,跟 htmx 沒有關係

這一頁要掛上去才進得來,Application.kt 的 routing { } 先放在原本那個 authenticate(TODO_AUTH) 裡

       authenticate(TODO_AUTH) {

           // ...

+          todoUiPage(repository)
       }

拿著前面那個 $AT 打一次

curl -s -H "Authorization: Bearer $AT" localhost:8080/ui

整頁抓出來是

<!DOCTYPE html>
<html>
  <head>
    <title>Todo</title>
    <script src="https://unpkg.com/htmx.org@2.0.4"></script>
  </head>
  <body>
    <h1>Todo</h1>
    <ul id="todo-list">
      <li id="todo-1">[x] 買牛奶</li>
      <li id="todo-2">[ ] 繳電費</li>
      <li id="todo-3">[ ] 寫 day 05 的文章</li>
    </ul>
    <form hx-post="/todos" hx-target="#todo-list" hx-swap="outerHTML"><input type="text" name="title"><button type="submit">新增</button></form>
  </body>
</html>

這裡把 htmx 固定在實測時使用的 2.0.4,不把它當成會自動更新的 latest,正式環境還要決定是否改成自己託管,如果繼續用 CDN,版本與 SRI 必須一起固定

認證撞牆

上面那頁是拿著 Bearer token 用 curl 抓出來的,真的在瀏覽器的網址列打 http://localhost:8080/ui 開開看,收到的不是那一頁

瀏覽器導覽只會送出它自己那幾個 header,用 curl 模擬的話是這樣,沒有 Authorization,只有一個瀏覽器會帶的 Accept

curl -i -s -H 'Accept: text/html,application/xhtml+xml' localhost:8080/ui

回來的是

HTTP/1.1 401 Unauthorized
X-Request-Id: ft6=cdk0ps23
X-Response-Time: 4ms
WWW-Authenticate: Bearer realm="todo-api"
Content-Length: 71
Content-Type: application/json

{"status":401,"message":"請帶著有效的 token 再來","details":[]}

3 件事同時錯,而且每一件單獨看都是前面某一天做對的決定

瀏覽器導覽送不出 Authorization header,點連結、打網址列這些動作沒有地方讓你塞自訂 header,這個客戶端在 day 26 那篇裡沒有出現過,字面上的意思,整篇 grep「瀏覽器」是 0 次

WWW-Authenticate: Bearer 對瀏覽器沒有意義,這個 header 是 day 27 為了 RFC 6750 一路調到符合的,換成 Basic 瀏覽器會跳一個帳密框出來,Bearer 不會,它什麼都不做

回應是 application/json,那條「401 的 body 不走 ContentNegotiation」的規矩是 day 26 定下來的,理由是狀態碼跟 challenge 在認證那一層就定了,不該讓 client 的 Accept 把它改成 406,day 28 為了 403 也要用而把它抽成 respondFixedJson,對機器是對的,對著人的螢幕就是一段看不懂的字串

htmx 的 fragment 請求也一樣,帶 HX-Request 那條也是 401,功能全部是好的,只是沒有人進得去

換成 cookie session,然後發現它可以偽造

瀏覽器交不出 header,但它會自己帶 cookie,所以這一輪讓 server 多認一種身分

session 裡要放什麼,寫在 src/main/kotlin/com/cashwu/todo/Auth.kt,跟 day 26 的 TODO_AUTH 那幾個常數放在一起

const val SESSION_COOKIE = "todo_session"
const val SESSION_AUTH = "todo-session"

@Serializable
data class TodoSession(val name: String, val roles: Set<String> = emptySet())

install(Sessions) 加在 src/main/kotlin/com/cashwu/todo/Application.kt 的 install(Authentication) 前面,第 1 版是照文件最短的寫法

+    install(Sessions) {
+        cookie<TodoSession>(SESSION_COOKIE) {
+            cookie.path = "/"
+            cookie.httpOnly = true
+            cookie.extensions["SameSite"] = "Lax"
+        }
+    }
+
     install(Authentication) {
         todoAuth(config.auth, issuer)
+        todoSessionAuth()
     }

Sessions 這個 plugin 只負責把 TodoSession 讀出來跟寫回去,「帶著這個 cookie 算不算通過認證」是另一件事,那是一個 provider,也放在 Auth.kt

fun AuthenticationConfig.todoSessionAuth() {
    session<TodoSession>(SESSION_AUTH) {
        validate { session -> TodoUser(session.name, session.roles) }
        challenge {
            call.respondRedirect("$UI_PATH/login")
        }
    }
}

validate 把 TodoSession 換成 day 26 那個 TodoUser,這一步是關鍵,day 28 的 authorize 檢查的就是 TodoUser 身上的 roles,所以 cookie 這條路只要在這裡接回同一個型別,後面整套授權一行都不用改

challenge 是沒通過的時候要做什麼,這一版無條件轉去登入頁,下一節會發現這樣不行

再來要有地方把 cookie 種下去,登入頁跟它的 POST 寫在 TodoHtml.kt

fun Route.loginPageRoutes(directory: UserDirectory) {
    get("$UI_PATH/login") {
        call.respondHtml {
            head { title { +"登入" } }
            body {
                h1 { +"登入" }
                form(action = "$UI_PATH/login", method = FormMethod.post) {
                    input(type = InputType.text, name = "name")
                    input(type = InputType.password, name = "password")
                    button(type = ButtonType.submit) { +"登入" }
                }
            }
        }
    }
    post("$UI_PATH/login") {
        val parameters = call.receiveParameters()
        val user = directory.authenticate(
            parameters["name"].orEmpty(),
            parameters["password"].orEmpty(),
        ) ?: throw ApiException(HttpStatusCode.BadRequest, "帳號或密碼不正確")
        call.sessions.set(TodoSession(user.name, user.roles))
        call.respondRedirect(UI_PATH)
    }
}

directory.authenticate 是 day 26 那個帳密比對,跟 /login 換 JWT 走的是同一個,差別只在後面那 2 行,一個是把 token 回給呼叫端,一個是 call.sessions.set(...) 把身分寫進 cookie 再把人送回 /ui。這幾條路由怎麼掛進 routing { }、/todos 為什麼開始認 session,是下一節的事

這份範例跑在本機 HTTP,所以沒有直接把 cookie.secure 設成 true,否則瀏覽器不會在 HTTP 連線送出 cookie,部署到 HTTPS 前必須打開,但 day 33 只會驗證容器與環境變數的邊界,這筆先留在債務清單

用 curl 送一次登入表單,-d 送出去的就是瀏覽器那個 form 會送的東西

curl -i -s -X POST localhost:8080/ui/login -d 'name=alice&password=alice-secret'

Set-Cookie 長這樣

HTTP/1.1 302 Found
X-Request-Id: ind2pq=ku7d8
Location: /ui
Set-Cookie: todo_session=%7B%22name%22%3A%22alice%22%2C%22roles%22%3A%5B%22user%22%2C%22admin%22%5D%7D; Max-Age=604800; Expires=Sat, 12 Sep 2026 02:11:27 GMT; Path=/; HttpOnly; SameSite=Lax; $x-enc=URI_ENCODING
X-Response-Time: 8ms
Content-Length: 0

URL decode 之後就是 {"name":"alice","roles":["user","admin"]},整個 session 明文放在客戶端手上,沒有簽章

所以不用登入也可以自己造一個,src/main/resources/application.yaml 的名單裡,bob 只有 user 這 1 個角色

34:      - name: "$BOB_NAME:bob"
35-        password: "$BOB_PASSWORD"
36-        roles:
37-          - user

先確認正常情況下 bob 真的刪不掉東西,用他自己的帳密登入拿一個真的 cookie,再打 DELETE

curl -s -i -X POST localhost:8080/ui/login -d 'name=bob&password=bob-secret' | grep -i '^set-cookie'
BOB=$(curl -s -i -X POST localhost:8080/ui/login -d 'name=bob&password=bob-secret' \
  | grep -i '^set-cookie' | sed 's/.*todo_session=\([^;]*\).*/\1/')
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE -H "Cookie: todo_session=$BOB" localhost:8080/todos/10
Set-Cookie: todo_session=%7B%22name%22%3A%22bob%22%2C%22roles%22%3A%5B%22user%22%5D%7D; Max-Age=604800; Expires=Sat, 12 Sep 2026 02:11:41 GMT; Path=/; HttpOnly; SameSite=Lax; $x-enc=URI_ENCODING
403

然後把那個 JSON 改成 bob 加 admin,URL encode 一次

FORGED=$(python3 -c 'import urllib.parse; print(urllib.parse.quote("""{"name":"bob","roles":["user","admin"]}""", safe=""))')
echo $FORGED
%7B%22name%22%3A%22bob%22%2C%22roles%22%3A%5B%22user%22%2C%22admin%22%5D%7D

這個值沒有經過任何登入,是自己打出來的,拿去打只有 admin 能做的 DELETE,12 是為了這次實測另外開的一筆

curl -s -o /dev/null -w '%{http_code}\n' -X DELETE -H "Cookie: todo_session=$FORGED" localhost:8080/todos/12

同一個 bob,自己登入是 403,自己捏一個 cookie 是 204,東西真的被刪掉了,day 28 那個 authorize 要的是使用者身上有沒有 admin,這裡它拿到了,因為那份角色清單是偽造的人自己填的

修法是在同一個 cookie<TodoSession> block 裡加一段 transform,變成這樣

install(Sessions) {
    cookie<TodoSession>(SESSION_COOKIE) {
        cookie.path = "/"
        cookie.httpOnly = true
        cookie.extensions["SameSite"] = "Lax"
        transform(
            SessionTransportTransformerMessageAuthentication(
                config.auth.jwt.secret.toByteArray()
            )
        )
    }
}

加上去之後重新登入,cookie 尾巴多了一段 HMAC

Set-Cookie: todo_session=%7B%22name%22%3A%22alice%22%2C%22roles%22%3A%5B%22user%22%2C%22admin%22%5D%7D%2Fc54b75892f26e603779548c6fd81c10158e51249d519a6a267b3c6b24536a42d; Max-Age=604800; Expires=Sat, 12 Sep 2026 02:12:03 GMT; Path=/; HttpOnly; SameSite=Lax; $x-enc=URI_ENCODING

%2F 是斜線,後面 64 個十六進位字元是簽章,同一個偽造的 cookie 再打一次

curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' \
  -X DELETE -H "Cookie: todo_session=$FORGED" localhost:8080/todos/9
302 http://localhost:8080/ui/login

那個 302 是中間狀態的產物,當時下一節的 wantsHtml() 還沒寫,session 的 challenge 無條件轉址,所以簽章擋下來之後就被送去登入頁,補上 wantsHtml() 之後,同一個偽造 cookie 的請求分成 4 種

for accept in '' 'Accept: application/json' 'Accept: text/html' 'HX-Request: true'
do
  curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' \
    -X DELETE -H "Cookie: todo_session=$FORGED" ${accept:+-H "$accept"} localhost:8080/todos/9
done
401
401
302 http://localhost:8080/ui/login
302 http://localhost:8080/ui/login

4 行依序是無 Accept(curl 預設 */*)、Accept: application/json、Accept: text/html、HX-Request: true

回 401 還是回 302 看的是誰在問,被擋下來這件事 4 種都一樣,後面那個測試斷言的是第 2 種,401

**transform 不是 install(Sessions) 的預設值,要自己寫,**官方文件上 cookie session 最短的那個範例沒有它,而少了它跟有它的差別,是整套角色授權有沒有在做事

還有一件事要分清楚,簽章保證的是「沒有被改過」,不是「看不到」,上面那串 %7B%22name%22... 任何人 decode 一次就讀得出來裡面是 alice 跟她的 2 個角色,所以密碼、身分證字號那種東西不能放進去,要放得換成 server side 的 SessionStorage,客戶端只拿一個 id

讓兩種客戶端共存的代價

現在有 2 種進得來的方式了,authenticate 可以收多個 provider,Application.kt 的 routing { } 實際變成這樣

    routing {
         tokenRoutes(directory, issuer)
+        loginPageRoutes(directory)
         authenticate(TODO_AUTH) {
             meRoute()
             todoEventsRoute(events)
         }
+        authenticate(SESSION_AUTH, TODO_AUTH) {
+            todoRoutes(repository, events)
             todoUiPage(repository)
         }
    }

原本那個 authenticate(TODO_AUTH) 沒有被改成 2 個 provider,是多開了一個 block,把 todoRoutes 從舊的搬到新的

/me 跟 /todos/events 留在只認 JWT 的那個 block 裡,帶 cookie 的瀏覽器到現在還是進不去,這一輪只讓 /todos 跟 /ui 2 種身分都收,另一個是 loginPageRoutes(directory) 那一行的縮排,它在 authenticate 外面而不是裡面,因為登入頁自己要登入的話,未登入的人會被轉去登入頁、登入頁再把他轉去登入頁

現在跑測試的話會有一堆失敗,全部是原本斷言 401 的變成 302,這個 provider 排序下,session 的 challenge 先完成回應,未登入的請求就全部被轉去登入頁,包含那些拿 curl 打 API 的

修法是讓 session 的 challenge 先看客戶端是誰,src/main/kotlin/com/cashwu/todo/Auth.kt 多一個判斷函式

fun ApplicationCall.wantsHtml(): Boolean {
    if (request.headers[HX_REQUEST_HEADER] != null) return true
    val accept = request.headers[HttpHeaders.Accept] ?: return false
    return accept.contains(ContentType.Text.Html.toString())
}

同一個檔案裡的 session provider 用它決定要不要回應

fun AuthenticationConfig.todoSessionAuth() {
    session<TodoSession>(SESSION_AUTH) {
        validate { session -> TodoUser(session.name, session.roles) }
        challenge {
            if (call.wantsHtml()) {
                call.respondRedirect("$UI_PATH/login")
            }
        }
    }
}

不是瀏覽器就什麼都不做,Ktor 的 challenge 是一條鏈,沒有人完成回應就換下一個,所以 jwt 那個 challenge 會接手,該回的 401 還是回 401,改完之後還有一些失敗的測試

剩下的失敗大部分不是這篇造成的

失敗散在好幾個 class 上,看起來像是路由改壞了一片,紅的是 TodoRoutesTest、RequestValidationTest、TestApplicationTest、OpenApiTest 跟 TodoSocketTest,把它們各自單獨跑一次

OpenApiTest > every route the api serves shows up in the spec() FAILED

隔離跑之下只有 OpenApiTest 還是紅的,其他都通過,也就是說只有它那個失敗是真的,其餘那些只有在全套跑的時候才失敗,訊息長這樣,這一則是 TodoRoutesTest 的

org.opentest4j.AssertionFailedError: expected: <{"id":4,"title":"倒垃圾","done":false,"created_at":"2026-10-10T12:00:00Z"}> but was: <{"id":8,"title":"倒垃圾","done":false,"created_at":"2026-10-10T12:00:00Z"}>

id 從 4 變 8,標題、狀態、時間全部一樣,只有流水號多跑了 4 格,那是資料污染不是路由壞掉

原因在測試的資料庫名,src/test/kotlin/com/cashwu/todo/TestApp.kt 那個 h2Database helper

整套測試共用 jdbc:h2:mem:todo 這一個名字,H2 的 in-memory 資料庫要等最後一條連線關掉才消滅,所以只要有連線池還沒關乾淨,下一個測試就會看到上一個留下的資料

這個坑從 day 20 就埋在那裡,那篇當時的說法是「jdbc:h2:mem:todo 這種 in-memory 資料庫在最後一條連線斷掉的時候整個消失」,這句話沒有錯,錯的是「最後一條連線斷掉」跟「下一個測試開始」之間沒有人保證先後,20 幾天沒踩到,是因為執行時序剛好都排得開,這一輪多出來的路由改變了時序,它才浮出來

改成每個測試 application 自己一個資料庫,h2Database 是 src/test/kotlin/com/cashwu/todo/TestApp.kt 裡的一個 lambda,也就是前面那個 helper,而 todoApplication() 是拿它去蓋設定的,所以只要改這一個地方,所有走 todoApplication() 起來的測試都會拿到不一樣的資料庫名

+private val h2Counter = java.util.concurrent.atomic.AtomicInteger()
+
 val h2Database: MutableMap<String, String>.() -> Unit = {
-    put("todo.database.url", "jdbc:h2:mem:todo")
+    put("todo.database.url", "jdbc:h2:mem:todo${h2Counter.incrementAndGet()}")
     put("todo.database.driver", "org.h2.Driver")
     put("todo.database.user", "sa")
     put("todo.database.password", "")
 }

計數器放在檔案的 top level,跟 h2Database 同一層,不是放進那個 lambda 裡面,不然每次套用都會重新開一個從 0 開始的計數器。改完之後名字會變成 jdbc:h2:mem:todo1、todo2 這樣一路長下去,每個 application 一個,誰都看不到誰留下的資料

再跑一次,還紅的剩下 OpenApiTest 跟 TestApplicationTest,這 2 個來源不一樣。OpenApiTest 那個路徑清單要補 /ui 跟 /ui/login,它就是前面唯一那個真的失敗;TestApplicationTest 那個比對資料庫網址的斷言則是剛剛那個修法自己弄出來的新失敗,它隔離跑本來是通過的,是 h2Counter 把網址從 jdbc:h2:mem:todo 改成後面帶號碼才對不上,改成前綴比對

-        assertEquals("jdbc:h2:mem:todo", config.property("todo.database.url").getString())
+        assertTrue(config.property("todo.database.url").getString().startsWith("jdbc:h2:mem:todo"))

OpenApiTest 那個失敗長這樣

org.opentest4j.AssertionFailedError: expected: <[/, /login, /refresh, /me, /todos, /todos/{id}, /todos/import/{externalId}]> but was: <[/, /login, /refresh, /ui/login, /me, /todos/import/{externalId}, /todos, /todos/{id}, /ui]>

多出來的是 /ui 跟 /ui/login 這 2 條,但同時 /todos/import/{externalId} 也跑到 /todos 前面去了,因為 todoRoutes 從舊的 authenticate block 搬到新的那個,註冊順序就跟著換。順序不是這個測試想驗的東西,它要驗的是「有沒有哪一條路由沒進規格」,所以 src/test/kotlin/com/cashwu/todo/OpenApiTest.kt 那個斷言改成兩邊都排序過再比

         assertEquals(
-            listOf("/", "/login", "/refresh", "/me", "/todos", "/todos/{id}", "/todos/import/{externalId}"),
-            paths,
+            listOf(
+                "/",
+                "/login",
+                "/me",
+                "/refresh",
+                "/todos",
+                "/todos/import/{externalId}",
+                "/todos/{id}",
+                "/ui",
+                "/ui/login",
+            ),
+            paths.sorted(),
         )

所以這一段其實是 3 筆帳,401 變 302 那一批是這一輪自己造成的,隔離跑會過的那些是二十幾天前就埋著的,而修那個舊坑又當場生出第 3 筆,修一個共用狀態的問題會動到所有讀那份狀態的斷言,這件事在動手之前先數一下有幾個地方寫死了那個值會比較快

規格看不到 htmx 這回事

day 29 那套 OpenApiDocSource.Routing 是在 runtime 走路由樹產生規格的,所以合理的預期是 hx { } 加的東西它會看到,把最終的程式碼跑起來抓 /swagger/documentation.yaml,跟加 htmx 之前的那份比一次,變動只有 3 類

多出 /ui 跟 /ui/login 2 條路徑,securitySchemes 底下多一個 todo-session,還有搬進 authenticate(SESSION_AUTH, TODO_AUTH) 的那幾條路由各多一行 - todo-session: []。3 類都是 authenticate 跟 install(Sessions) 帶來的,沒有一項來自 hx { }

直接數 2 個字串

curl -s localhost:8080/swagger/documentation.yaml > /tmp/spec.yaml
grep -c 'HX-Request' /tmp/spec.yaml
grep -c 'CreateTodoRequest' /tmp/spec.yaml
0
3

hx { } 在規格上一點痕跡都沒有,HX-Request 不是一個 header 參數,/todos 的 post 底下也沒有多一個 operation,day 29 手寫的那份描述原封不動,CreateTodoRequest 的 $ref 全檔還是出現 3 次

這裡有一個誘人但不對的解釋,說產生器看不到這種依 header 分流的分支,第 1 版的寫法剛好可以拿來反證,那時 hx { } 是掛在一個獨立的 route("/todos") 底下的,規格上會長出 - name: HX-Request 跟 in: header 這個參數,併進既有的 route("/todos") 之後才不見,同一個產生器、同一個分支,只換了掛的位置,結果從有變成沒有

差別在同一個路徑加同一個方法上出現了 2 個 operation,併進去之後,hx 分支的 GET 跟 POST 跟 day 29 describeJson 過的 GET 跟 POST 落在同一格,而 paths 底下一個方法只放得下一個,最後留下的是手寫的那份

所以拿規格當清單的人,不會知道 /todos 除了 JSON 之外還有一種 HTML 的回應形式

被抓到的則是 authenticate 帶來的東西,securitySchemes 底下多了一個

    todo-session:
      name: todo_session
      in: cookie
      description: Session-based Authentication
      type: apiKey

in: cookie、type: apiKey、名字叫 todo_session,這些沒有一個是手寫的,這一輪沒有寫過任何一行 describe { },而搬進 authenticate(SESSION_AUTH, TODO_AUTH) 的 /todos、/todos/{id} 跟 /ui 各多一行 - todo-session: [],留在 JWT-only 那個 block 的 /me 跟 /todos/import/{externalId} 就沒有

至於新加的那 2 條 UI 路由

  /ui/login:
    post: {}
    get: {}

/ui/login 的 get 跟 post 都是完全空的 operation,連 summary 這個 key 都沒有,全檔唯一那個 summary: "" 在 /ui 的 get 上,值是空字串,這是 UI 這一側一行 describe { } 都沒補的直接結果

規格上看不到 hide() 藏起來的東西,那是有人明確決定不要寫進去,hide() 就寫在原始碼上,這一輪不一樣,是 2 個 operation 撞在同一格,被擠掉的那個沒有人選過,同樣是規格上少一塊,一個是刻意的,一個是撞出來的,而讀規格的人 2 種都看不出來

測試

新增一個檔案 src/test/kotlin/com/cashwu/todo/TodoHtmlTest.kt

同一個 URL 2 種回應這件事要有一個測試驗證

@Test
fun `the same url answers html to htmx and json to everyone else`() = testApplication {
    val client = todoApplication()

    val fragment = client.get("/todos") { header(HX_REQUEST_HEADER, "true") }
    val json = client.get("/todos")

    assertEquals(HttpStatusCode.OK, fragment.status)
    assertTrue(fragment.bodyAsText().startsWith("""<ul id="todo-list">"""), fragment.bodyAsText())
    assertTrue(json.bodyAsText().startsWith("[{"), json.bodyAsText())
}

2 種客戶端拿到不一樣的待遇,那是前面 wantsHtml() 那段的意思,2 個測試各驗一邊

@Test
fun `a browser without a session is redirected to the login page`() = testApplication {
    todoApplication()

    val response = createClient { followRedirects = false }.get(UI_PATH) {
        header(HttpHeaders.Accept, ContentType.Text.Html.toString())
    }

    assertEquals(HttpStatusCode.Found, response.status)
    assertEquals("$UI_PATH/login", response.headers[HttpHeaders.Location])
}

@Test
fun `an api client without a token still gets a 401 not a redirect`() = testApplication {
    todoApplication()

    val response = createClient { followRedirects = false }.get("/todos") {
        header(HttpHeaders.Accept, ContentType.Application.Json.toString())
    }

    assertEquals(HttpStatusCode.Unauthorized, response.status)
    assertTrue(response.headers[HttpHeaders.WWWAuthenticate]!!.startsWith("Bearer"))
}

那個 followRedirects = false 是刻意寫的,Ktor 的 client 預設會自己跟著轉址走,不關掉的話拿到的是登入頁的 200,斷言就檢查不到那個 302

偽造 cookie 那件事寫成 2 個測試,同一個檔案的最上面放那份假身分

private const val FORGED_COOKIE =
    """{"name":"bob","roles":["user","admin"]}"""

DELETE 那個是這樣

@Test
fun `an unsigned cookie cannot delete either`() = testApplication {
    todoApplication()

    val response = createClient { followRedirects = false }.delete("/todos/1") {
        header(HttpHeaders.Cookie, "$SESSION_COOKIE=${encode(FORGED_COOKIE)}")
        header(HttpHeaders.Accept, ContentType.Application.Json.toString())
    }

    assertFalse(response.status == HttpStatusCode.NoContent, "偽造的 cookie 不可以刪掉東西")
    assertEquals(HttpStatusCode.Unauthorized, response.status)
}

2 個斷言分工不一樣,assertEquals 那個管的是現在的行為,assertFalse 那個管的是那條紅線,把訊息寫進去是因為它失敗的時候,看的人第 1 眼要知道這不是回應碼變了,是簽章沒有在做事

另外 4 個沒有貼出來的,the ui page carries the htmx attributes the fragment route needs 斷言頁面上有 hx-post、hx-target、hx-swap 3 個屬性,logging in sets a signed session cookie 斷言 Set-Cookie 有 HttpOnly 而且值裡面有編碼過的斜線,an unsigned cookie does not authenticate 是 GET 版本,a real session cookie can post through htmx 走完登入拿 cookie 再用 cookie 發 htmx POST 這一整條

跟 Relix 的對照

這一節材料比前幾篇少,因為 Relix 那個系列沒有做 HTML 渲染,也沒有 session,它的 Todo API 從頭到尾是 JSON,認證是一份寫死的 bearer lookup table,所以沒有可以逐行對照的東西

能對照的是取捨的形狀,Relix day 30 的 main() 裡,那個 bearer { } 上面有一行註解

    // 正式環境換成查資料庫或驗 JWT

那句話預設了一件事,客戶端會自己帶 header,這一篇踩到的就是那個預設不成立的時候會怎樣

手刻框架的時候,「認證」很容易被簡化成「檢查一個 header」,因為那是最容易做出來也最容易測的形狀,而瀏覽器不送 header,它送 cookie,然後 cookie 帶來一整組新問題,要簽章、要決定裡面放什麼、要處理登出、challenge 要轉址而不是回 401,這幾件事這一輪解決了 2 件,簽章跟 challenge 要轉址,而後者花掉的篇幅比簽章還多,登出跟「裡面放什麼」還欠著,在下面那份清單裡

Relix 那邊實際加 cookie 會長什麼樣,我沒有那個專案可以跑,答不出來,可以確定的只有那行註解涵蓋的範圍,它講的是「怎麼認出這個 token 屬於誰」,不是「這個客戶端有沒有辦法把 token 交出來」

小結

這篇讓同一條 /todos 路由多出 HTML fragment 的回應,JSON API 沒有另外複製一套,hx { } 只處理帶 HX-Request 的請求,原本的 client 仍拿到 JSON,瀏覽器則用 session cookie 通過同一組授權規則

session 的第 1 版把身分與角色明文交給客戶端,沒有簽章時可以直接偽造 admin,加上 SessionTransportTransformerMessageAuthentication 後,內容仍看得到,但竄改會被拒絕,challenge 也必須分辨 HTML 與 API client,否則 session provider 會把原本應該拿到 401 的請求轉成 302

整套測試最後全部通過,過程也修掉測試共用 H2 資料庫名稱造成的污染,瀏覽器入口能用了,但那是功能上能用,不是安全上做完了

這篇留下的問題有 5 個,沒有登出,cookie 的 Max-Age 是 604800 秒,7 天內都有效而且撤銷不了,使用者的角色被移除之後舊 cookie 還是帶著原本那份角色,跟 day 27 那個 refresh token 是同一筆債,本機 HTTP 沒有設定 cookie.secure,部署到 HTTPS 前必須打開,cookie 會自動跟著 htmx 的 POST、PUT 與 DELETE 送出,SameSite=Lax 只能降低部分跨站請求風險,這一輪還沒有 CSRF token 或 Origin 檢查,session 的內容是明文,簽章只保證沒被改過,要放敏感資料得換成 server side 的 SessionStorage,而那把簽章金鑰現在跟 JWT 共用同一個 config.auth.jwt.secret,2 個用途應該分開,最後是規格這一側,hx { } 那個分支進不了 OpenAPI,/ui 跟 /ui/login 也一行 describe { } 都沒寫,拿規格當清單的人看不到 HTML 這半邊


下一篇

下一篇會把服務打包成 fat JAR 與 Docker image,補上健康檢查,觀察資料庫正常與失聯時的關機差異,再把 HTTPS cookie、secret manager 與 graceful drain 這些還沒完成的部署工作列清楚


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Ktor 實戰 101 Day 31 Ktor HttpClient 與 MockEngine
系列文
Kotlin Ktor 實戰 101 共 32 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言