QUERY 不是 GET with body:HTTP 終於承認我們一直在用 POST 做查詢
RFC 10008 標準化 HTTP QUERY 方法後,API 設計終於有方法表達安全、冪等、可帶 body 的複雜查詢;但瀏覽器、CORS、快取、WAF 與 CDN 還沒完全跟上。
每個後端專案裡,大概都有一支長得像這樣的 API:
POST /products/search
Content-Type: application/json
{
"filters": {
"price": { "gte": 1000, "lte": 5000 },
"brands": ["sony", "panasonic"],
"features": ["wifi", "4k", "hdr"]
},
"sort": ["-rating", "price"],
"page": 3
}
它很好用,也很合理。前端可以把複雜條件放進 JSON,後端不用解一串失控的 query string,測試工具也看得懂。
唯一尷尬的是:這支 API 其實沒有「post」任何東西。
它沒有建立商品,沒有送出訂單,沒有改變伺服器狀態。它只是在查資料。只是這個查詢太大、太複雜、太結構化,塞不進一條漂亮的 URL,所以我們把它放進 POST /search。久而久之,大家都知道這是查詢,但 HTTP 基礎設施不知道。快取不知道,重試策略不知道,代理伺服器也只能從 method 猜它可能會改狀態。
RFC 10008 標準化的 HTTP QUERY 方法,就是在補這個洞。
不是因為 POST /search 是壞設計。剛好相反:POST /search 太普遍了,普遍到足以證明 HTTP 少了一個剛好的語意位置。
POST /search 不是罪,它是沒有標準時的生存策略
過去我們在 GET 和 POST 之間選邊站,常常不是因為其中一邊完美,而是因為另一邊更糟。
GET 的語意很漂亮:安全、冪等、可快取、可分享。你把查詢條件寫在 URI 裡,瀏覽器歷史、書籤、CDN、代理伺服器全都能理解。
GET /products?brand=sony&minPrice=1000&maxPrice=5000&sort=-rating
條件一多,畫面就開始難看。巢狀物件要怎麼編碼?陣列用 brand=sony&brand=panasonic,還是 brand[]=sony&brand[]=panasonic?查詢條件裡如果有一整段 JSON 呢?URL 太長時,瀏覽器、反向代理、API gateway 誰先擋下來?敏感篩選條件被寫進 access log,又算誰的責任?
所以大家改用 POST。
POST /products/search
Content-Type: application/json
{
"brand": ["sony", "panasonic"],
"price": { "gte": 1000, "lte": 5000 },
"sort": ["-rating"]
}
工程上很舒服。語意上有點心虛。
這張表大概就是過去二十年的 API 設計日常:
複雜查詢想要的能力
語意正確 可帶 body 可快取 可安全重試
GET ✅ ❌ ✅ ✅
POST ❌ ✅ △ ❌
QUERY ✅ ✅ ✅ ✅
QUERY 的出現,不是拿來審判以前寫 POST /search 的人。它只是把那個大家早就知道的尷尬說出口:我們需要一個「安全、冪等、但可以帶 request body」的查詢方法。
QUERY 補的是語意,不是魔法
最容易把 QUERY 說錯的方式,是把它叫成「GET with body」。
這個說法好記,但不夠準。
比較準確的心智模型應該是這樣:
GET
請把這個 URI 所代表的資源給我。
POST
請讓這個 URI 處理我送來的內容;結果由 server 語意決定。
QUERY
請在這個 URI 的範圍內,用我送來的查詢內容,
安全且冪等地算出結果。
RFC 10008 對 QUERY 的定義很直接:request target 會以安全且冪等的方式處理 enclosed content,然後回傳處理結果。這讓 QUERY 比 POST 多了一個重要承諾:相同查詢可以在連線失敗後重送,不必擔心半途改了狀態。
一個 QUERY request 可能長這樣:
QUERY /products HTTP/1.1
Content-Type: application/json
Accept: application/json
{
"filter": {
"price": { "gte": 1000, "lte": 5000 },
"brand": ["sony", "panasonic"]
},
"sort": ["-rating", "price"]
}
這裡的 Content-Type 不是裝飾。RFC 10008 要求 server 在缺少 Content-Type,或內容和 media type 不一致時拒絕請求。因為 QUERY 的查詢語意來自 request content,而 request content 要靠 media type 解讀。
Server 也可以用 Accept-Query 告訴 client:這個資源支援哪些查詢格式。
HTTP/1.1 200 OK
Content-Type: application/json
Accept-Query: application/json, application/jsonpath
這個 header 的價值,不只是在文件上多一行。它讓 client 有機會用 HTTP 本身探索查詢能力,而不是只靠人類讀 Swagger 或 README。
更有意思的是 Location 和 Content-Location。
HTTP/1.1 200 OK
Content-Type: application/json
Location: /stored-queries/42
Content-Location: /stored-results/17
Content-Location 可以指向這次查詢結果。Location 可以指向一個等價查詢資源。這是 RFC 10008 裡最容易被略過、但其實很漂亮的設計。
它承認一件事:body 裡的查詢不好分享,不好 bookmark,也不好讓後續請求直接重放。所以 server 可以替這次查詢或結果補上一個 URI。換句話說,QUERY 沒有背叛 Web 的 URI 哲學;它只是承認有些查詢一開始真的很難塞進 URI。
真正麻煩的不是發出 QUERY,是快取它
QUERY response 可以被快取。這句話聽起來像好消息。
但好消息後面接著工程帳單。
GET 的快取 key 很直覺:URL,再加上相關 header。
GET 快取 key
/products?brand=sony&sort=-rating
+ Accept-Language
+ Authorization / Vary 相關資訊
↓
cache hit / miss
QUERY 不一樣。查詢條件在 body 裡,所以 cache key 必須納入 request content 和相關 metadata。
QUERY 快取 key
/products
+ Content-Type: application/json
+ Accept: application/json
+ body:
{"brand":["sony"],"sort":["-rating"]}
↓
body hash / normalization
↓
cache hit / miss
這裡開始有坑。
如果 cache 只看 URL,兩個完全不同的查詢都叫 QUERY /products,就可能撞在同一個 cache entry。這不是效能問題,是安全問題。安全研究者已經開始提醒:body-keyed cache 如果做錯,會打開 cache poisoning 或 cache deception 的門。
比較保守的做法,是 byte-for-byte hash request body。但這會讓語意相同、格式不同的 JSON 失去 cache hit:
{"brand":["sony"],"sort":["-rating"]}
和:
{
"sort": ["-rating"],
"brand": ["sony"]
}
對 application 來說可能是一樣的查詢,對 byte hash 來說不是。
如果 cache 想聰明一點,先 normalize JSON,再算 key,又會進入另一個風險區:你確定 cache 的 normalization 規則,和 application 對查詢語意的理解完全一致嗎?陣列順序能不能換?大小寫能不能折疊?空字串和缺欄位是不是同一件事?
QUERY 讓查詢「可以」被快取,但它沒有讓快取「自動變簡單」。如果你的 CDN 仍然只用 URL 當 key,那 QUERY /search 反而可能比 POST /search 更危險,因為它看起來像一個可以快取的東西。
社群對這點的反應很真實。有人在 Hacker News 上直接說:現在的 cache 軟體主要看 headers 和 query string,現在還要擴到 body,這只是更多程式碼。反駁也很直接:重點不是少寫幾行,而是 client、proxy 和 retry policy 能知道這個 request 可以安全重送。
兩邊都沒錯。標準補上語意,實作要付成本。
前端這關還沒過,別急著把 public API 全換掉
如果你今天在瀏覽器裡寫:
await fetch('/api/products', {
method: 'QUERY',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ brand: ['sony'] }),
});
故事不會只停在「瀏覽器能不能送出這個 method」。
QUERY 不是 forbidden method,但它也不是 CORS safelisted method。Fetch Standard 目前的 CORS safelisted methods 是 GET、HEAD、POST。所以跨來源 QUERY 會走 preflight。
Browser
|
| 想送 QUERY /api/products
v
OPTIONS /api/products
Access-Control-Request-Method: QUERY
|
v
Gateway / WAF / Load Balancer
|
| 允許 QUERY 嗎?OPTIONS 規則正確嗎?
v
App Server
|
| router 認得 QUERY 嗎?middleware 會讀 body 嗎?
v
CDN / Cache
|
| cache key 有納入 body 嗎?
v
Response
這條鏈上任何一段不認得 QUERY,你都會得到一個很無聊的錯誤:405、403、CORS error,或某個 WAF 覺得你在做奇怪的事。
更麻煩的是,瀏覽器與 Fetch 生態還在補整合細節。WHATWG Fetch issue 已經有人提出 QUERY 的 method normalization、CORS 行為與 HTTP cache 模型問題。裡面有一個很實務的觀察:目前不該依賴 Chrome 或 Firefox 對重複 QUERY 做瀏覽器層快取。
所以 public browser API 現在不適合豪賭。
對前端來說,QUERY 比較像「可以開始設計」,不是「可以全面替換」。尤其是需要分享網址、SEO、書籤、瀏覽器上一頁下一頁狀態的場景,GET 仍然是正解。你不能把所有篩選條件藏進 request body,然後期待使用者複製網址時一切都還在。
有些查詢就是應該活在 URL 裡。
GET /products?status=active&page=1
如果你的查詢條件只有這樣,請放過 QUERY。標準不是拿來裝飾 API 的。
誰可以先用?誰應該先等等?
我會把 QUERY 的導入分成兩種世界。
第一種是 service-to-service。兩端都由你控制,中間沒有太多未知 gateway、WAF、CDN,查詢 body 又真的大而結構化。這時候 QUERY 很適合拿來試。
適合先試
- service-to-service
- client 和 server 都由同一團隊控制
- 查詢條件大而結構化
- retry / idempotency 很重要
- 中間鏈路可控,能明確放行 QUERY
- cache 策略由 application 或受控 proxy 處理
.NET 10 已經開始走在前面。System.Net.Http.HttpMethod.Query 和 ASP.NET Core 的 HttpMethods.IsQuery 都已出現在官方文件。Kreya 1.20 也已經把 QUERY 加進 API 測試工具的支援範圍。OpenAPI 3.2 則能在 spec 裡描述 query operation。
這些訊號很重要,代表工具鏈開始動了。
但第二種世界是 public API,尤其是 browser client。這裡我會保守很多。
不急著換
- 使用者需要分享網址或 bookmark
- SEO 是需求的一部分
- public browser API
- 中間有未知 CDN / WAF / API gateway
- 團隊還沒驗證 CORS preflight
- 只是簡單 filter / pagination
公開 API 的問題不是你能不能在 controller 裡接到 QUERY。問題是整條路徑上的每個角色都要理解它。瀏覽器、preflight、gateway、WAF、observability、cache、文件、SDK、測試工具,只要有一段還停在「我只認得 GET/POST/PUT/DELETE」的年代,你就得準備 fallback。
比較務實的遷移方式:三軌並行
我不會建議把所有 POST /search 一次改成 QUERY。
比較務實的設計,是三軌並行:
GET /products?status=active&page=1
→ 簡單查詢、可分享、可 bookmark
POST /products/search
→ 既有複雜查詢 fallback,支援舊 client
QUERY /products
→ 新 client、service-to-service、可控環境中的複雜查詢
OpenAPI 3.2 裡可以把 QUERY 寫進文件:
paths:
/products:
get:
summary: Simple product filtering
query:
summary: Advanced product search
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
filter:
type: object
sort:
type: array
items:
type: string
但文件支援不等於整條鏈路支援。OpenAPI 3.2 可以描述 QUERY,不代表你的 gateway 已經願意放行它,也不代表你的 SDK generator、mock server、contract test、API client 都準備好了。
真正的導入檢查表應該長這樣:
QUERY readiness checklist
Client
□ HTTP client 可以送 QUERY + body
□ method 大小寫不會被奇怪 normalization 破壞
□ retry policy 把 QUERY 視為 idempotent
Browser / CORS
□ OPTIONS preflight 正確回應
□ Access-Control-Allow-Methods 包含 QUERY
□ error monitoring 能分辨 preflight 失敗
Gateway / WAF / Proxy
□ method allowlist 包含 QUERY
□ request body 不會被丟棄
□ security rules 不會把 QUERY 當未知攻擊
Cache
□ cache key 納入 request body
□ Content-Type / Accept / Vary 規則明確
□ normalization 規則和 application 語意一致
Observability
□ logs 顯示 QUERY method
□ tracing / metrics 能分組
□ audit policy 知道 body 可能含查詢條件
Documentation
□ OpenAPI / SDK / 測試工具支援
□ fallback 策略寫清楚
□ client migration guide 說明何時用 GET、POST、QUERY
這份清單看起來很長,因為它本來就不只是改一個 controller attribute。
QUERY 的價值不是取代 POST,而是讓系統說實話
POST /search 會繼續存在很久。它太好用,也太相容。很多 public API 甚至應該繼續保留它,直到瀏覽器、gateway、WAF、CDN、SDK 和文件工具都追上來。
RFC 10008 的意義不在於明天把所有 search endpoint 改名。它真正補上的,是一個讓 HTTP 基礎設施看懂意圖的機會。
這不是建立資源。
不是送出命令。
也不是偷偷改狀態。
這只是一個查詢。
二十年後,HTTP 終於有一個 method 可以誠實地說出這句話。
參考資料
- RFC 10008: The HTTP QUERY Method,IETF Datatracker:https://datatracker.ietf.org/doc/rfc10008/
- Fetch Standard,WHATWG:https://fetch.spec.whatwg.org/
- Integrating the HTTP QUERY method,WHATWG Fetch issue:https://github.com/whatwg/fetch/issues/1938
- HTTP Methods,OpenAPI Documentation:https://learn.openapis.org/specification/http-methods.html
- HttpMethod.Query Property,Microsoft Learn:https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpmethod.query?view=net-10.0
- Support for HTTP QUERY rfc10008,Node.js Undici issue:https://github.com/nodejs/undici/issues/5454
- The new HTTP QUERY method explained,Kreya:https://kreya.app/blog/new-http-query-method-explained/
- RFC 10008: The New HTTP QUERY Method and the Attack Surface Still Catching Up,Hive Security:https://hivesecurity.gitlab.io/blog/http-query-method-rfc-10008-attack-surface/
- RFC 10008: The new HTTP Query Method,Hacker News 討論:https://news.ycombinator.com/item?id=48568502