iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Software Development

Kotlin Ktor 實戰 101系列 第 11 篇

Kotlin Ktor 實戰 101 Day 11 CORS 與 RateLimit

  • 分享至 

  • xImage
  •  

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

這篇要裝 2 個現成的守門 plugin,CORS 管瀏覽器的跨網域請求該不該放行,RateLimit 替整個 application 設請求上限,2 個都是一行 install 就能用的官方 plugin,但設定錯了不會在啟動時爆炸,只會在某個請求進來時默默回絕,所以這篇反過來走,先讓它失敗,從 403 和 429 的實際回應回推每個設定管的是哪一關,另外,前 2 篇都說這類守門 plugin 掛在 internal Validators phase 那一帶,這次翻 3.5.2 原始碼對答案,結果對了一半

這篇要完成什麼

  • 加上 CORS 與 RateLimit 的 dependencies,裝進 todo-api
  • 用 preflight 被拒的實測回推 allowHost、allowMethod、allowHeader 各自管哪一關
  • 連打共享限額到 429,看 Retry-After 和 X-RateLimit 系列 header 怎麼描述限額
  • 翻原始碼驗證前 2 篇說的「Validators 那一帶」,CORS 全對,RateLimit 對一半
  • 補 6 個測試固定關鍵行為,最後交代這 2 個 plugin 的去留
  • DefaultHeaders 和 Compression 順帶認識一下

先加 dependencies

CORS 和 RateLimit 都不在 server-core 裡,Ktor 的官方 plugin 每個都是獨立的 artifact,要用那個裝那個,打開 build.gradle.kts,在 dependencies 加 2 行,day 02 設好的 version catalog 這時候就顯出好處,打個點 IDE 就把 accessor 名稱補出來

implementation(ktorLibs.server.cors)
implementation(ktorLibs.server.rateLimit)

CORS 到底在管什麼

CORS 的全名是 Cross-Origin Resource Sharing,中文一般翻成跨來源資源共用,名字本身就把這個機制講完了,瀏覽器預設有一條叫同源政策 (Same-Origin Policy) 的規矩,不同來源之間讀不到彼此的東西,CORS 則是規格留給 server 的表態管道,讓它指名放行某幾個來源,把資源共用出去

瀏覽器眼中的 origin 是 3 件事湊起來的,scheme、host、port,任何一個不同就是不同 origin,前端開發時跑在 http://localhost:5173,todo-api 跑在 http://localhost:8080,host 都是 localhost,看起來像同一台機器,但 port 不一樣,對瀏覽器來說就是 2 個不同的 origin,http://localhost:5173 跟 https://localhost:5173 也是 2 個 origin,差在 scheme

回到同源政策,網頁上的 JavaScript 想讀另一個 origin 的回應,得先看那個 server 願不願意,server 表態的方式是在回應加上 Access-Control-Allow-Origin 這類 header,沒表態的話,請求其實已經送到 server、server 也照常回了,只是瀏覽器把回應擋在 JavaScript 拿不到的地方,前端 fetch() 這時候拿到的通常只有一句模糊的 network error,看不出來是哪一關沒過,這也是 CORS 問題難查的原因

要注意這整套是瀏覽器的機制,curl 打同一支 API 完全不受影響,另一個後端服務拿 HTTP client 呼叫也一樣,它們根本不執行同源政策,所以 CORS 不是安全邊界,它決定的是「哪些網頁能在瀏覽器裡讀到你的回應」,不是「誰能呼叫你的 API」,該做的 authentication 一樣得做

preflight 是瀏覽器在送出某些跨來源請求之前,先發一個 OPTIONS 到同一個網址問路的動作,內容大意是「我等一下要用 DELETE、還會帶這幾個 header,可以嗎 ?」,等 server 回答允許,真正的請求才送出去,這個 OPTIONS 由 server 上處理 CORS 的那一層直接回答,不會走到你寫的 route,preflight 沒過的話,真正的請求根本不會發出去,前端拿到的一樣是那句模糊的 network error

觸發 preflight 的條件有 3 類,method 不是 GET、HEAD、POST,像 PUT、DELETE、PATCH 都算,請求帶了安全清單 (CORS-safelisted request header) 以外的 header,像 Authorization 或自訂的 X-Request-Id,或者 Content-Type 不是 application/x-www-form-urlencoded、multipart/form-data、text/plain 這 3 種簡單型別,現代的 JSON API 光是 Content-Type: application/json 就已經不算簡單請求,跨來源打過來的每一支,前面幾乎都會多一次 preflight

CORS,先用最小設定裝上

todo-api 遲早會有前端來打,前端就是剛剛那個 http://localhost:5173,在 Application.kt 的 module 裡先用最小設定裝上

install(CORS) {
    allowHost("localhost:5173")
}

這裡的 import 要挑對,是 io.ktor.server.plugins.cors.routing.CORS,套件路徑裡有 routing,舊位置 io.ktor.server.plugins.cors.CORS 還在,但已經標了 deprecated 而且等級是 ERROR,IDE 跳出 2 個候選時選有 routing 的那個

allowHost 收 host 不收完整網址,scheme 是另一個參數,預設同時允許 http 和 https,因此 allowHost("localhost:5173") 會放行 http://localhost:5173 與 https://localhost:5173,如果開發環境只想允許 http,要明確寫成 allowHost("localhost:5173", schemes = listOf("http"))

這篇後面一直講的白名單,指的就是 allowHost 設定出來的這份允許來源清單,它只管 origin 這一關,等一下會看到 method 和 header 各自還有自己的清單,那 2 份不叫白名單,講白名單就是在講 origin

文件裡還有一個 anyHost(),讓任何 origin 的網頁都能在瀏覽器裡讀取回應,它放寬的只有瀏覽器那一關,curl 或 server-to-server 呼叫本來就不受它影響

讓 preflight 失敗一次

用 ./gradlew run 起 server,假設前端現在要刪一筆 todo,會發出 DELETE /todos/1,DELETE 不是簡單請求,瀏覽器會先發一個 OPTIONS 去問,這就是 preflight,用 curl 模擬瀏覽器的問法

curl -i -X OPTIONS \
  -H "Origin: http://localhost:5173" \
  -H "Access-Control-Request-Method: DELETE" \
  localhost:8080/todos/1
HTTP/1.1 403 Forbidden
Vary: Origin
X-Response-Time: 2ms
Content-Length: 0

直接被拒絕了,origin 明明在白名單裡,還是收到 403,因為 CORS 的設定是一關一關的,allowHost 只管來源這一關,method 是另一關,Ktor 預設只放行 GET、POST、HEAD,DELETE 要自己開,回到設定補一行

install(CORS) {
    allowHost("localhost:5173")
    allowMethod(HttpMethod.Delete)
}

header 是第 3 關,不過這一關要 preflight 真的問了才會檢查,剛剛那個 curl 沒帶 Access-Control-Request-Headers,preflight 就沒有 header 要審,所以照樣 200,只是 Access-Control-Allow-Headers 空著

HTTP/1.1 200 OK
Vary: Origin
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: DELETE, GET, HEAD, POST
Access-Control-Allow-Headers:
Access-Control-Max-Age: 86400
X-Response-Time: 3ms
Content-Length: 0

前端如果習慣帶自訂的 X-Request-Id 追蹤請求,瀏覽器就會把它列進 preflight 的 Access-Control-Request-Headers,用 curl 把這個 header 補上再打一次

curl -i -X OPTIONS \
  -H "Origin: http://localhost:5173" \
  -H "Access-Control-Request-Method: DELETE" \
  -H "Access-Control-Request-Headers: X-Request-Id" \
  localhost:8080/todos/1
HTTP/1.1 403 Forbidden
Vary: Origin
X-Response-Time: 3ms
Content-Length: 0

403 就又出現了,簡單請求以外的 header 都要明講,再補一行 allowHeader("X-Request-Id"),重開 server 打同一個 curl

HTTP/1.1 200 OK
Vary: Origin
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: DELETE, GET, HEAD, POST
Access-Control-Allow-Headers: X-Request-Id
Access-Control-Max-Age: 86400
X-Response-Time: 3ms
Content-Length: 0

順帶一個觀察,Access-Control-Allow-Headers 回的是你設定的清單,不是把請求問的東西原封不動回敬,所以現在改回不帶 Access-Control-Request-Headers 的那個 curl,它一樣會列出 X-Request-Id

3 個關卡都過了,server 把允許的範圍一次講清楚,host、method 和 header,這份答案還可以快取 86400 秒 (1 天) 不需要再問

跨域請求本體的三種結果

preflight 只是問路,真正的請求還要再走一次 CORS 檢查,3 種情境各打一次,第 1 種,白名單裡的 origin

curl -i -H "Origin: http://localhost:5173" localhost:8080/todos
HTTP/1.1 200 OK
Vary: Origin
Access-Control-Allow-Origin: http://localhost:5173
X-Response-Time: 9ms
Content-Length: 40
Content-Type: text/plain; charset=UTF-8

買牛奶
繳電費
寫 day 05 的文章

回應多了 Access-Control-Allow-Origin,瀏覽器看到它才會把資料交給 JavaScript,第 2 種,換一個不在白名單裡的 origin

curl -i -H "Origin: http://evil.example.com" localhost:8080/todos
HTTP/1.1 403 Forbidden
Vary: Origin
X-Response-Time: 0ms
Content-Length: 0

這裡 Ktor 做了一個跟 Relix 相反的決定,Relix day 17 選的是照樣回 200 和完整資料,只是不加 CORS header,讓瀏覽器去擋,server-to-server 的呼叫完全不受影響,Ktor 選擇在 server 端直接擋下來,403、空的 body,2 種做法當時那篇就比較過,基本上都可以,而 Ktor 站在比較保守的那邊

第 3 種,不帶 Origin header,例如 curl 直接打或後端對打

curl -i localhost:8080/todos
HTTP/1.1 200 OK
Vary: Origin
X-Response-Time: 0ms
Content-Length: 40
Content-Type: text/plain; charset=UTF-8

買牛奶
繳電費
寫 day 05 的文章

200 和完整資料,沒有任何 Access-Control 開頭的 header,plugin 看到沒有 Origin 就直接放行,這也是為什麼裝了它,日常的 curl 實測和既有測試都沒感覺,同源請求是另一條判斷,瀏覽器即使帶了 Origin,只要它跟 server 的 origin 相同,預設的 allowSameOrigin = true 也會放行

這 3 種情境的回應都帶著 Vary: Origin,連沒帶 Origin 的那個也有,用 allowHost 指名放行的時候,回應內容跟著請求的 Origin 變,同一個網址可能回 200 也可能回 403,要跟快取講清楚,這件事 Relix 那篇也是跟著規格做的,兩邊有共識

RateLimit,打到 429 為止

todo-api 還沒有能力面對突然暴增的請求,不管是失控的 retry 迴圈還是惡意流量,先給全站一個共享上限,在 module 裡接著裝

install(RateLimit) {
    global {
        rateLimiter(limit = 5, refillPeriod = 10.seconds)
    }
}

RateLimit 從 io.ktor.server.plugins.ratelimit.Ratelimit 來,10.seconds 則要 import kotlin.time.Duration.Companion.seconds,這組設定是一個 token bucket,桶裡放 limit 塊代幣,每個請求進來拿一塊,拿不到就被拒,每過 refillPeriod 整桶補滿,5 個請求 10 秒是故意設定的,馬上就能撞到牆

這裡沒有設定 requestKey,Ktor 預設用同一個 Unit 當 key,因此所有來源共用這 5 塊代幣,不是每個 client 各有 5 塊,正式系統如果要依 client、API key 或使用者分開計算,必須自己提供 requestKey,如果拿 IP 當 key,還要先處理可信任的 reverse proxy header,不能直接相信外部送來的 X-Forwarded-For

重開 server,拿同一個 curl 連打 /todos,看 header 怎麼變

curl -i localhost:8080/todos

第 1 次長這樣

HTTP/1.1 200 OK
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 4
X-RateLimit-Reset: 1788305175
Vary: Origin
X-Response-Time: 9ms
Content-Length: 40
Content-Type: text/plain; charset=UTF-8

買牛奶
繳電費
寫 day 05 的文章

plugin 預設就把限額狀態放進 3 個 header,上限 5、剩 4、X-RateLimit-Reset 是下次補滿的 UTC timestamp (秒),接著把同一個指令連打,Remaining 一路遞減到第 5 次歸零,第 6 次

HTTP/1.1 429 Too Many Requests
Retry-After: 9
X-Response-Time: 0ms
Content-Length: 0

429 Too Many Requests,附一個 Retry-After: 9,告訴 client 再等 9 秒。這個數字是剩餘等待時間無條件進位到秒,寫得好的 client 會照著這個 header 排 retry,而不是瞎猜,乖乖等 9 秒後再打同一個 curl,200 回來了,X-RateLimit-Remaining: 4,整桶真的補滿了

那個 429 的回應,有一個細節,前面每個回應都有的 Vary: Origin 不見了,CORS 明明還裝著,它的 header 卻沒出現,這不是 bug,是下一節的線索

對答案,它們掛哪個 phase

day 09 發現 internal 的 Validators phase 時,原始碼註解點名 authentication、rate limiting、CORS,前 2 篇都說這篇的 2 位主角「掛那一帶」,現在翻 3.5.2 的原始碼對答案

先看 CORS。CORS.kt 的 buildPlugin() 整段邏輯掛在哪個 hook 上

@Suppress("INVISIBLE_REFERENCE")
onCallValidators { call ->
    // Origin 檢查、preflight 處理、補 CORS header 全在這裡
}

onCallValidators,就是上一篇在 PluginBuilder 裡看到的那個 internal hook,對應 Validators phase,這部分前 2 篇說對了,有意思的是那行 @Suppress("INVISIBLE_REFERENCE"),這個 hook 是 internal,我們自己寫 plugin 時用不到,官方 plugin 要用也得壓掉編譯器對 internal API 的存取檢查

再看 RateLimit,答案開始分岔,RateLimitInterceptors.kt 裡定義了 2 個 plugin,掛的 phase 不同

  • RateLimitInterceptors 是 route-scoped 版,掛 Validators phase,用法是 install(RateLimit) 時用 register 註冊具名限流器,再到 routing 裡用 rateLimit { } 圈住要限的那段路由
  • RateLimitApplicationInterceptors 是 application 層級版,掛的是 Plugins phase,我們設定裡的 global { } 裝的就是它

所以「掛在 Validators 那一帶」對 CORS 全對,對這篇用的 global RateLimit 其實是 Plugins,而 day 09 驗過 phase 順序,Plugins 在 Validators 前面,這正好解釋了剛剛的線索,第 6 次的請求進來,RateLimit 在 Plugins phase 先跑,代幣拿不到,直接用 call.respond 把 429 送出去,輪到 Validators phase 時,CORS 的 interceptor 開頭檢查 call.response.isCommitted,發現回應已經送出去就整段跳過,Vary: Origin 自然不會出現,2 個 plugin 的相對位置,就這樣出現在 429 的 header 上

另外,RateLimit 的實作把限流器的註冊表放在 application.attributes,把這個請求用到的限流器放在 call.attributes,一個活得跟 application 一樣久,一個跟著請求消失,上一篇 RequestTiming 用 attributes 跨 hook 傳時間戳,就是同一套機制在不同生命週期的用法

DefaultHeaders 與 Compression

眼尖的話會發現,這篇每個 curl 輸出都沒有 Server 和 Date header,Netty engine 不會主動加上,DefaultHeaders 這個 plugin 的作用就是補這件事,裝上後每個回應都帶 Server: Ktor/3.5.2 和標準格式的 Date,也能在設定裡加自訂的固定 header

Compression 則是看請求的 Accept-Encoding 協商壓縮,gzip、deflate 都支援,回應大時能省不少流量,2 個都是加上對應 artifact 再寫一行 install,todo-api 目前回應的 body 才 40 bytes,壓縮沒有意義,Server header 是有安全性的問題,基本上不要隨便就暴露版本,所以沒有預設啟用也是正常的,這裡先知道有這 2 個 plugin 就好,需要時回來裝

用測試把行為固定,兩個都從 module 拿掉

這 2 個 plugin 會影響測試怎麼寫,所以先講去留,這次 2 個的結論一樣,都不留在 module 裡

CORS 的白名單綁著 localhost:5173 這種示範值,todo-api 目前沒有前端,day 29 要裝的 Swagger UI 跟 API 同源,後面 htmx 那個瀏覽器入口也是同源,整個系列都碰不到跨來源這件事

RateLimit 拿掉的理由不一樣,5 個請求 10 秒是為了馬上撞牆才設的,這種值留在 module 裡就是後面每一篇的地雷,任何一個連打幾次的實測或測試都會突然拿到 429,換成 1 分鐘 100 個這種寬鬆上限確實不會再撞到,但它還有第 2 個代價,X-RateLimit-Limit、X-RateLimit-Remaining、X-RateLimit-Reset 這 3 行會出現在之後每一個回應上,後面文章的 curl 輸出都要多帶 3 行跟當篇主題無關的東西,所以留在 dependencies 和測試裡就好,正式環境要限流的時候再裝回去

2 個的行為都要固定下來,測試都不走 module(),每個測試自己建最小的 application,跟上一篇 RequestTimingTest 測 config 用的同一招,開一個新檔案 src/test/kotlin/com/cashwu/todo/CorsTest.kt,4 個測試把 CORS 的關鍵行為各自固定一件

package com.cashwu.todo

import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.options
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.cors.routing.CORS
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

class CorsTest {
    private fun Application.corsModule() {
        install(CORS) {
            allowHost("localhost:5173")
            allowMethod(HttpMethod.Delete)
            allowHeader("X-Request-Id")
        }
        routing {
            get("/todos") {
                call.respondText("ok")
            }
        }
    }

    @Test
    fun `preflight for allowed method responds ok with cors headers`() = testApplication {
        application {
            corsModule()
        }

        val response = client.options("/todos") {
            header(HttpHeaders.Origin, "http://localhost:5173")
            header(HttpHeaders.AccessControlRequestMethod, "DELETE")
        }

        assertEquals(HttpStatusCode.OK, response.status)
        assertEquals("http://localhost:5173", response.headers[HttpHeaders.AccessControlAllowOrigin])
        assertEquals("DELETE, GET, HEAD, POST", response.headers[HttpHeaders.AccessControlAllowMethods])
    }

    @Test
    fun `preflight for method not allowed responds forbidden`() = testApplication {
        application {
            install(CORS) {
                allowHost("localhost:5173")
            }
            routing {
                get("/todos") {
                    call.respondText("ok")
                }
            }
        }

        val response = client.options("/todos") {
            header(HttpHeaders.Origin, "http://localhost:5173")
            header(HttpHeaders.AccessControlRequestMethod, "DELETE")
        }

        assertEquals(HttpStatusCode.Forbidden, response.status)
        assertNull(response.headers[HttpHeaders.AccessControlAllowOrigin])
    }

    @Test
    fun `request from disallowed origin responds forbidden`() = testApplication {
        application {
            corsModule()
        }

        val response = client.get("/todos") {
            header(HttpHeaders.Origin, "http://evil.example.com")
        }

        assertEquals(HttpStatusCode.Forbidden, response.status)
        assertNull(response.headers[HttpHeaders.AccessControlAllowOrigin])
    }

    @Test
    fun `request without origin header is not affected`() = testApplication {
        application {
            corsModule()
        }

        val response = client.get("/todos")

        assertEquals(HttpStatusCode.OK, response.status)
        assertEquals("ok", response.bodyAsText())
        assertNull(response.headers[HttpHeaders.AccessControlAllowOrigin])
    }
}

corsModule 這個 helper 是修好之後的完整設定,第 2 個測試故意不用它,自己裝一個只有 allowHost 的版本,把這篇開頭那個「白名單過了、method 沒過」的 403 重現成斷言,哪天有人以為 allowHost 一行就搞定 CORS,這個測試會提出反證,後面 2 個測試回頭固定跨來源請求本體的行為,一個是不在白名單的 origin 拿 403,一個是不帶 Origin header 完全不受影響,後者是敢把 CORS 裝上任何專案的前提,它保證 curl 實測和其他既有測試都不會因為裝了這個 plugin 而改變

再開一個新檔案 src/test/kotlin/com/cashwu/todo/RateLimitTest.kt,2 個測試

package com.cashwu.todo

import io.ktor.client.request.get
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.ratelimit.RateLimit
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.time.Duration.Companion.hours

class RateLimitTest {
    private fun Application.rateLimitModule() {
        install(RateLimit) {
            global {
                rateLimiter(limit = 2, refillPeriod = 1.hours)
            }
        }
        routing {
            get("/todos") {
                call.respondText("ok")
            }
        }
    }

    @Test
    fun `requests within limit succeed and carry remaining count`() = testApplication {
        application {
            rateLimitModule()
        }

        val first = client.get("/todos")
        val second = client.get("/todos")

        assertEquals(HttpStatusCode.OK, first.status)
        assertEquals("1", first.headers["X-RateLimit-Remaining"])
        assertEquals(HttpStatusCode.OK, second.status)
        assertEquals("0", second.headers["X-RateLimit-Remaining"])
    }

    @Test
    fun `request over limit responds too many requests with retry after`() = testApplication {
        application {
            rateLimitModule()
        }

        repeat(2) { client.get("/todos") }
        val exhausted = client.get("/todos")

        assertEquals(HttpStatusCode.TooManyRequests, exhausted.status)
        assertNotNull(exhausted.headers[HttpHeaders.RetryAfter])
    }
}

限額在測試裡改成 2,第 3 次就撞牆,不用打 6 次,refillPeriod 設成一小時也是故意的,實測用的 10 秒在測試裡是不確定性的來源,跑得慢一點桶就補滿了,斷言會時好時壞,拉長到測試絕對跑不完的長度,行為就穩定了,Retry-After 只驗存在不驗數值,數值跟時間點有關,驗了反而脆弱,真的要驗的話,就讓它大於現在的時間就好,不過其實沒有太大的意義

測試寫完,把 module 裡的 install(CORS) 和 install(RateLimit) 都拿掉,Application.kt 回到只有 RequestTiming 一個 plugin,2 個測試 class 都自己拼最小的 module,不靠全域那份設定,所以拿掉之後照樣通過

跟 Relix 的對照

這篇的 2 個 plugin,跟 Relix 的關係剛好一個有、一個沒有,對照的角度也就不同

CORS 是 Relix day 17 手刻過的,那篇從 Origin 的定義做到 preflight 短路、Vary: Origin、還踩過 JDK header 大小寫正規化的坑,手刻過一次的好處這篇直接兌現,Ktor 的 preflight 回應裡每個 header 為什麼在那裡、Vary 為什麼連被拒的回應都有,全部看得懂,要比對的只剩實作決策的差異,差異有 3 處,preflight 的狀態碼 Relix 回 204、Ktor 回 200,被拒的 origin,Relix 讓資料照常回,由瀏覽器去擋,Ktor 在 server 端直接回 403

preflight 的認定,Relix 要求 OPTIONS 帶著 Access-Control-Request-Method 才算,Ktor 對跨域的 OPTIONS 一律走 preflight 流程,沒帶那個 header 就當檢查失敗回 403

RateLimit 則是 Relix 沒做過的東西,但這篇讀它的原始碼一點都不吃力,靠的不是做過,是機制拆過,它的 429 短路是 day 09 拆過的那套「respond 之後,後面的人自己檢查、默默跳過」,day 09 的預設 404 看的是 call.isHandled,這篇的 CORS 看的是 isCommitted,零件不同、形狀一樣,它的 2 個 attributes 是 day 10 用過的傳話手法,它掛 Plugins 還是 Validators,day 09 的 phase 表直接查,手刻的價值就在這裡,造過幾個輪子之後,別人的輪子拆開來每個零件都認得


小結

CORS 的設定一關管一件事,allowHost 管來源、allowMethod 管方法、allowHeader 管自訂 header,哪關沒過 preflight 就是 403,Ktor 選擇在 server 端直接擋

RateLimit 用 token bucket 描述限額,代幣用完回 429 加 Retry-After,X-RateLimit 系列 header 隨時報告餘額,這篇沒有設定 requestKey,所以所有來源共用同一個限額

CORS 真的掛在 Validators phase,global 的 RateLimit 掛的是 Plugins,route-scoped 版才在 Validators,429 回應上消失的 Vary: Origin 就是 phase 順序的證據


下一篇

todo-api 回的一直都是純文字,下一篇進入新的段落,內容協商與序列化,裝 ContentNegotiation,讓 API 回真正的 JSON,day 08 裝過的 kotlinx.serialization 編譯器外掛會再登場,data class 進、JSON 出,todo-api 終於開始長得像一支現代的 API


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Ktor 實戰 101 Day 10 自訂 Plugin
下一篇
Kotlin Ktor 實戰 101 Day 12 ContentNegotiation 與 kotlinx.serialization
系列文
Kotlin Ktor 實戰 101 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言