# 用 Python 實作 RFC 10008 的 HTTP QUERY 方法：它和 GET、POST 差在哪

> 以 Python 與 Starlette 實作 RFC 10008 的 HTTP QUERY：帶結構化請求主體，同時安全、冪等、可快取的請求，以及它與 GraphQL 的互補關係。

- Source: https://oharu121.com/zh-tw/blog/http-query-method-rfc10008-python-demo/
- Published: 2026-08-11T00:47:19+09:00
- Tags: HTTP, Web API, Python

---
## 引言

2026 年 6 月，一個新的 HTTP 方法 **QUERY** 正式標準化為 [RFC 10008](https://datatracker.ietf.org/doc/html/rfc10008)。這個方法取了 GET 與 POST 的優點，我實際用 Python 寫了一個伺服器並跑起來看看。

https://github.com/oharu121/http-query-method-rfc10008-demo

### TL;DR

*Figure — RequestFlow: 同時具備結構化主體與可快取、安全請求的，只有 QUERY。*

## QUERY 方法是什麼

在 HTTP 上設計搜尋 API 時，開發者長年抱著這個兩難。

| 特性 | GET | POST | **QUERY** |
|------|-----|------|-----------|
| 請求主體 | 無 | 有 | **有** |
| 安全 | Yes | No | **Yes** |
| 冪等 | Yes | No | **Yes** |
| 可快取 | Yes | 受限 | **Yes** |

- **GET** 安全、冪等、可快取，但不能帶主體。複雜的搜尋條件只能塞進 URL 查詢字串，於是要與 URL 長度上限（實務上約 2048 個字元）和巢狀結構的表達方式搏鬥
- **POST** 可以帶主體，但語意是「會改變狀態的操作」。代理伺服器與快取會判定它「有副作用」，因此快取不易生效，自動重試也不安全

QUERY 這個方法在維持 GET 的安全性、冪等性、可快取性的同時，還能像 POST 一樣送出結構化的請求主體。

## Safe、Idempotent、Cacheable 是什麼，為什麼重要

針對上表出現的三個特性，以下逐一具體說明。

### Safe（安全）

指送出請求**不會改變伺服器的狀態**。用 GET 讀一個頁面是安全的，用 DELETE 刪一筆記錄則不是。

為什麼重要：只要方法是「安全的」，瀏覽器、爬蟲、預先擷取機制就會自由地送出請求。2005 年 Google Web Accelerator 預先擷取連結時，偽裝成 GET 的 DELETE 操作被觸發，使用者的資料因此被刪除。「安全」這個宣告，是整個基礎設施自動化不可或缺的訊號。

*Image: 比較安全的 GET /page 與不安全的 DELETE /users/123，並顯示瀏覽器、爬蟲、預先擷取機制自由送出不會改變伺服器狀態的請求*

### Idempotent（冪等）

指同一個請求**送 1 次和送 100 次結果都相同**。GET、PUT、DELETE 是冪等的。POST 不是（付款送兩次，就可能被扣兩次款）。

為什麼重要：網路故障時的自動重試。請求逾時的話，只要是冪等的方法，用戶端或代理伺服器就能自動重送。因為重送 POST 並不安全，瀏覽器才會跳出「要重新送出表單嗎？」的確認對話框。

*Image: 比較冪等的 GET、PUT、DELETE 與每次送出都建立新付款的 POST /payments，並顯示網路逾時後的自動重試*

### Cacheable（可快取）

指回應可以被儲存下來，對相同的請求**重複使用**。

為什麼重要：效能。CDN 會把 GET 回應快取在全球各地的節點。POST 的回應一般不會被快取，因為快取機制認定 POST 是會改變狀態的操作，回應可能立刻就過期了。QUERY 和 GET 一樣可快取，但因為快取鍵也包含請求主體，不同的查詢主體會使用不同的快取項目。

*Image: 在 CDN 上比較可快取的 GET、不可快取的 POST，以及不同請求主體對應到不同快取項目的 QUERY*

## QUERY 不是 GET 或 POST 的替代品

QUERY 不是既有方法的**替換**，而是填補至今沒有適當方法可用的情境。

| 使用情境 | 適當的方法 | 理由 |
|-------------|--------------|------|
| 以 URL 取得資源 | **GET** | 簡單、通用，URL 本身就是資源的識別碼 |
| 少量參數的搜尋 | **GET** | `?q=shoes&color=red` 這種程度用 URL 就夠了 |
| 資源的建立、更新、刪除 | **POST/PUT/DELETE** | 改變狀態的操作 |
| 複雜結構化查詢的搜尋 | **QUERY** | 超出 GET 的 URL 限制的搜尋條件 |

**該用 GET 的場合：** 查詢塞得進 URL 的時候。簡單的篩選、分頁、關鍵字搜尋。今天大多數的搜尋 API 用 GET 都沒問題。

**該用 POST 的場合：** 真的要改變狀態的時候。建立記錄、送出表單、觸發動作。

**該用 QUERY 的場合：** 搜尋條件塞不進 URL 參數的時候。巢狀篩選、地理位置查詢、多個陣列條件、送出結構化查詢語言。在 QUERY 出現以前，這個用途只能挪用 POST，並犧牲可快取性。

*Image: GET、POST、QUERY 各自的請求範例與典型使用情境，並提到過去只能挪用 POST 的用途*

## 為什麼到現在才出現

RFC 10008 的共同作者是 Cloudflare 的 James Snell 與 Akamai 的 Mike Bishop。兩大 CDN 公司的工程師寫下這份規格，意味著 CDN 層級的 QUERY 支援有機會較早實現。

多年來，搜尋 API 上「`POST /search`」這個模式是事實上的標準，但它的語意其實是「建立一個搜尋資源」，與實際狀況脫節。QUERY 方法從根本解決了這個問題。

## 前提與環境

- Python 3.12+
- Starlette 0.46+（ASGI 框架）
- uvicorn 0.34+
- httpx 0.28+（用戶端）
- uv（套件管理工具）

範例程式碼在這個儲存庫：

https://github.com/oharu121/http-query-method-rfc10008-demo

## 範例的全貌

以商品型錄的搜尋 API 為題材，把同一個搜尋條件用 **GET、POST、QUERY** 三種方法執行，比較其中的差異。

搜尋條件範例：

```json
{
  "categories": ["laptops", "phones"],
  "price": {"min": 500, "max": 2000},
  "tags": ["pro"],
  "min_rating": 4.5,
  "in_stock": true,
  "near": {"lat": 35.68, "lng": 139.76, "radius_deg": 1.0},
  "sort": {"field": "price", "order": "desc"}
}
```

各欄位的意義：

| 欄位 | 型別 | 說明 |
|-----------|-----|------|
| `categories` | string[] | 目標分類。以陣列指定多個（OR 條件） |
| `price` | object | 價格區間。以 `min`/`max` 指定巢狀範圍 |
| `tags` | string[] | 商品標籤。需符合陣列內全部（AND 條件） |
| `min_rating` | number | 最低評分（0〜5） |
| `in_stock` | boolean | 只篩選有庫存的商品 |
| `near` | object | 以地理位置做鄰近搜尋。緯度、經度、半徑以巢狀指定 |
| `sort` | object | 排序條件。目標欄位與升冪／降冪以巢狀指定 |

值得注意的是 `price`、`near`、`sort` 都是巢狀物件。想用 GET 的查詢字串表達它們，就必須攤平成 `price_min=500&price_max=2000&near_lat=35.68&near_lng=139.76&near_radius=1.0` 這樣，結構因此消失。欄位越多 URL 就越長，很快就會碰到實務上的上限（約 2048 個字元）。

*Image: 巢狀的 QUERY JSON 主體，與同一個條件攤平成 GET 參數後失去欄位間關係的樣子並列，並顯示超過實務上限 2048 個字元的 URL*

## 伺服器實作

### 專案設定

```toml title="pyproject.toml"
[project]
name = "http-query-demo"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "starlette>=0.46",
    "uvicorn>=0.34",
    "httpx>=0.28",
]
```

```bash
uv sync
uv run uvicorn server:app --reload
```

### QUERY 方法的路由

在 2026 年 6 月的時間點，大多數 Web 框架都沒有原生支援 QUERY 方法。在 Starlette 裡，我是把自訂的方法名稱傳給 `Route` 的 `methods` 參數來處理的。

```python
async def search_dispatcher(request: Request) -> JSONResponse:
    """Dispatch to a handler according to the HTTP method"""
    match request.method:
        case "GET":
            return await search_via_get(request)
        case "POST":
            return await search_via_post(request)
        case "QUERY":
            return await search_via_query(request)
        case "OPTIONS":
            return await product_search_options(request)
        case _:
            return JSONResponse(
                {"error": f"Method {request.method} not allowed"},
                status_code=405,
                headers={"Allow": "GET, POST, QUERY, OPTIONS"},
            )

routes = [
    Route(
        "/products/search",
        search_dispatcher,
        methods=["GET", "POST", "QUERY", "OPTIONS"],
    ),
]

app = Starlette(routes=routes)
```

重點在於 Starlette 的 `Route` 會接受 `methods` 清單裡的任意字串。並不是框架端明確支援了「QUERY」，而是它接受了一個未知的方法名稱。

### GET 處理器：扁平查詢字串的極限

```python
async def search_via_get(request: Request) -> JSONResponse:
    params = request.query_params
    query: dict[str, Any] = {}

    if cats := params.get("categories"):
        query["categories"] = cats.split(",")
    if price_min := params.get("price_min"):
        query.setdefault("price", {})["min"] = int(price_min)
    if price_max := params.get("price_max"):
        query.setdefault("price", {})["max"] = int(price_max)
    # ... individual params like near_lat, near_lng, near_radius are needed
```

為了表達巢狀結構（`price.min`、`near.lat`），必須自行定義一套扁平的參數命名慣例（`price_min`、`near_lat`）。這需要用戶端與伺服器之間的隱含約定，寫進 OpenAPI 綱要也變得繁瑣。

### POST 處理器：會動，但語意是錯的

```python
async def search_via_post(request: Request) -> JSONResponse:
    body = await request.body()
    content_type = request.headers.get("content-type", "")
    if "json" not in content_type:
        return JSONResponse(
            {"error": "Content-Type must be application/json"},
            status_code=415,
        )

    query = json.loads(body)
    data = search_products(query)
    return JSONResponse(data)
```

程式碼很簡單，但問題出在 HTTP 語意。

- 代理伺服器與 CDN 把 POST 視為「有狀態變更的操作」，不會快取回應
- 網路故障時的自動重試並不安全（同一個 POST 送兩次，可能讓副作用發生兩次）
- 瀏覽器上一頁時詢問「要重新送出表單嗎？」，也是 POST 不安全的一種表現

### QUERY 處理器：符合 RFC 10008 的實作

```python
async def search_via_query(request: Request) -> JSONResponse:
    body = await request.body()

    # RFC 10008 §3: a Content-Type header is mandatory
    content_type = request.headers.get("content-type", "")
    if not content_type:
        return JSONResponse(
            {"error": "QUERY requests MUST include a Content-Type header (RFC 10008 §3)"},
            status_code=400,
        )

    # RFC 10008 §3: for an unsupported media type, respond 415 + notify supported types with Accept-Query
    if "json" not in content_type:
        return JSONResponse(
            {"error": f"Unsupported media type: {content_type}"},
            status_code=415,
            headers={"Accept-Query": '"application/json"'},
        )

    # RFC 10008 §4: a QUERY response is cacheable
    # include a hash of the request body in the cache key
    cache_key = _cache_key("QUERY", request.url.path, body)
    if cached := _get_cached(cache_key):
        return JSONResponse(cached, headers={"X-Cache": "HIT"})

    try:
        query = json.loads(body)
    except json.JSONDecodeError as e:
        # RFC 10008 §3: syntactically correct but semantically unprocessable → 422
        return JSONResponse(
            {"error": f"Unprocessable query content: {e}"},
            status_code=422,
        )

    data = search_products(query)
    _set_cache(cache_key, data)

    return JSONResponse(
        data,
        headers={
            "X-Cache": "MISS",
            "Accept-Query": '"application/json"',
        },
    )
```

RFC 10008 定義的錯誤處理要點：

| 情況 | 狀態碼 | 說明 |
|------|-----------------|------|
| 沒有 Content-Type 標頭 | 400 Bad Request | QUERY 必須要有 Content-Type |
| 不支援的媒體類型 | 415 Unsupported Media Type | 以 `Accept-Query` 標頭告知支援的類型 |
| 無法解析的主體 | 422 Unprocessable Content | 媒體類型正確，但內容無效 |

*Image: QUERY 請求失敗時分別回傳 400 Bad Request、415 Unsupported Media Type、422 Unprocessable Content 的表格，並附上各自的請求與回應範例*

### 快取的實作

QUERY 最大的好處是可快取性。和 GET 不同，快取鍵不只要包含 URI，還必須包含**請求主體**。

```python
def _cache_key(method: str, path: str, body: bytes) -> str:
    body_hash = hashlib.sha256(body).hexdigest()[:16]
    return f"{method}:{path}:{body_hash}"
```

RFC 10008 §4 允許快取把主體中「語意上不重要的差異」正規化。以 JSON 為例，鍵的順序或縮排差異可以忽略。不過，若用戶端指定了 `no-transform` 快取指令，就不得進行正規化。

## 驗證能不能動

### 用 curl 送出 QUERY 請求

```bash
curl -s -D - -X QUERY "http://localhost:8000/products/search" \
  -H "Content-Type: application/json" \
  -d '{
    "categories": ["laptops", "phones"],
    "price": {"min": 500, "max": 2000},
    "tags": ["pro"],
    "min_rating": 4.5,
    "in_stock": true,
    "near": {"lat": 35.68, "lng": 139.76, "radius_deg": 1.0},
    "sort": {"field": "price", "order": "desc"}
  }'
```

因為 curl 可以用 `-X QUERY` 指定任意 HTTP 方法，直接就能用。

### 第一次的回應（快取 MISS）

```
HTTP/1.1 200 OK
x-search-method: QUERY
x-cache: MISS
x-cache-key: QUERY:/products/search:7f73fb16e7395e7d
accept-query: "application/json"
x-note: Safe + idempotent + cacheable + structured body (RFC 10008)

{"total":1,"offset":0,"limit":10,"results":[{"id":4,"name":"iPhone 16 Pro",...}]}
```

### 第二次的回應（快取 HIT）

重送同一個請求：

```
HTTP/1.1 200 OK
x-search-method: QUERY
x-cache: HIT
x-cache-key: QUERY:/products/search:7f73fb16e7395e7d
```

以相同的快取鍵命中了。這種快取行為，依規格來說是 POST 做不到的。

### Python 用戶端（httpx）

```python
import httpx
import json

SEARCH_QUERY = {
    "categories": ["laptops", "phones"],
    "price": {"min": 500, "max": 2000},
    "tags": ["pro"],
}

with httpx.Client(base_url="http://localhost:8000") as client:
    # httpx supports a custom HTTP method via the request() method
    resp = client.request(
        "QUERY",
        "/products/search",
        content=json.dumps(SEARCH_QUERY),
        headers={"Content-Type": "application/json"},
    )
    print(resp.json())
```

因為 httpx 的 `client.request()` 第一個引數接受任意 HTTP 方法名稱，不用特別處理就能送出 QUERY 請求。

### 把 GET 的 URL 長度問題視覺化

從 Python 用戶端的執行結果，來看 GET 的 URL 長度：

```
GET /products/search?... (flat query string)
  → URL length: 212 chars
```

即使是這麼簡單的搜尋條件，也有 212 個字元。實務上搜尋條件可能達到 20〜30 項，很快就會碰到 URL 的實務上限（約 2048 個字元）。

### 確認錯誤處理

```
No Content-Type → 400: QUERY requests MUST include a Content-Type header (RFC 10008 §3)
Wrong Content-Type → 415: Unsupported media type: text/plain
  Accept-Query header: "application/json"
Malformed JSON → 422: Unprocessable query content: ...
```

多虧了 `Accept-Query` 標頭，用戶端可以自動得知「這個端點接受哪些媒體類型的 QUERY」。

## QUERY 方法的重要規格要點

### Accept-Query 標頭

伺服器可以在回應標頭回傳 `Accept-Query`，告知它在 QUERY 上支援的媒體類型。

```
Accept-Query: "application/json", application/sql;charset="UTF-8"
```

這會成為未來支援 JSON 以外查詢語言（類 SQL、JSONPath 等）的內容協商基礎。

### 重新導向的行為

QUERY 的重新導向和 POST 不同：

| 狀態碼 | 行為 |
|-----------|------|
| 301/308（永久） | 對新的 URI 重送 **QUERY** |
| 302/307（暫時） | 對新的 URI 重送 **QUERY** |
| 303（See Other） | 對新的 URI 送出 **GET** |

POST 曾有在 301/302 時方法被改成 GET 的曖昧行為，但 QUERY 定義得很明確。

*Image: 比較 301/308、302/307、303 上 QUERY 明確定義的重新導向行為，與 POST 在歷史上曖昧的行為的表格*

### 對 CORS 的影響

因為 QUERY 不在 CORS 安全清單上，從瀏覽器送出時需要預檢請求（OPTIONS）。

```
Access-Control-Allow-Methods: GET, POST, QUERY, OPTIONS
```

這可能影響瀏覽器用戶端的效能（會多出一次來回）。

*Image: 瀏覽器送出 OPTIONS 預檢並收到 204 No Content，接著送出實際的 QUERY 請求並取得 200 OK 的四步驟往來*

## 與 GraphQL 的關係：互補而非競爭

你可能會想：「QUERY 方法是不是想解決和 GraphQL 一樣的問題？」簡單說，兩者是**互補而非競爭**。它們運作在不同的層。

| | GraphQL | HTTP QUERY |
|---|---------|------------|
| **它是什麼** | 查詢語言＋執行環境 | 一種傳輸方法 |
| **層** | 應用層（如何表達查詢） | 協定層（如何送出查詢） |
| **它定義什麼** | 綱要、型別、解析器、欄位選擇 | 一個請求的安全性、冪等性、可快取性 |

GraphQL 定義要查詢**什麼**，HTTP QUERY 定義**如何**在 HTTP 上送出那個查詢。

*Image: 把應用層的查詢語言 GraphQL 與協定層的傳輸方法 HTTP QUERY 對照的表格，以及用 QUERY /graphql 送出 GraphQL 查詢的流程*

### GraphQL 目前的傳輸問題

今天的 GraphQL 主要以 POST 送出查詢：

```bash
# the common way GraphQL is sent today
curl -X POST https://api.example.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ products(category: \"laptops\") { name price } }"}'
```

於是 GraphQL 直接繼承了 POST 的問題：

- CDN 不會快取回應（POST 被視為狀態變更）
- 網路故障時沒有自動重試
- HTTP 層沒有安全性保證

也有一些 GraphQL 實作使用 GET（把查詢放進 URL），但複雜的 GraphQL 查詢很快就會碰到 URL 長度上限。

### QUERY 可以改善 GraphQL 的傳輸

把 GraphQL 的讀取查詢用 QUERY 方法送出，兩邊的好處都拿得到：

```bash
# GraphQL over HTTP QUERY — the ideal combination
curl -X QUERY https://api.example.com/graphql \
  -H "Content-Type: application/graphql+json" \
  -d '{"query": "{ products(category: \"laptops\") { name price } }"}'
```

CDN 可以判定「這是安全、冪等、可快取的」。GraphQL 的 mutation（資料變更）維持 POST 即可，因為 mutation 確實會改變狀態，POST 的語意才是正確的。

### 真正的競爭軸線

市場不需要在 GraphQL 和 QUERY 之間二選一。真正在競爭的是 API 設計哲學的層次：

| 比較 | 競爭？ |
|------|--------|
| GraphQL vs REST | Yes：API 設計取向的差異 |
| HTTP QUERY vs 挪用 POST 做搜尋 | Yes：QUERY 取代 POST 的權宜做法 |
| GraphQL vs HTTP QUERY | **No**：層不同，可以組合 |

反過來說，QUERY 對 GraphQL 團隊是好消息。因為不必改動 GraphQL 本身，HTTP 傳輸層的快取問題就有機會解決。

**同樣的互補關係，也適用於 JSON-RPC、gRPC-Web 等其他以 POST 送出請求的協定。**

## 框架支援狀況（2026 年 6 月時點）

| 框架 | QUERY 支援 | 備註 |
|---------------|-------------|------|
| Starlette | 可作為自訂方法使用 | `methods=["QUERY"]` 就能運作 |
| Express.js | 沒有實作 `app.query()` | 以 `app.all()` ＋手動分派處理 |
| FastAPI | 透過 Starlette 可行 | 沒有原生的裝飾器 |
| Spring Boot | 需要自訂註解 | `@RequestMapping(method="QUERY")` |
| Ruby on Rails | 討論中 | [論壇上有提案](https://discuss.rubyonrails.org/t/proposal-support-for-the-http-query-method-rfc-10008/91255) |

除非框架、反向代理伺服器、API 閘道、CDN、WAF 全都支援，否則正式環境很難用上。不過，規格的共同作者是 Cloudflare 與 Akamai 的工程師，這意味著 CDN 層級的支援可能會早一步到來。

## 總結

RFC 10008 的 HTTP QUERY 方法，是對「搜尋 API 只能用 POST」這個長年權宜做法的正式解答。

**QUERY 解決了什麼：**
- 送出 GET 做不到的結構化請求主體
- 取回 POST 失去的安全性、冪等性、可快取性
- 透過 `Accept-Query` 標頭做明確的內容協商

**目前的限制：**
- 框架的原生支援幾乎不存在（雖然不少框架能當成自訂方法處理）
- CDN、代理伺服器、WAF 的支援還在後頭
- 需要 CORS 預檢（對瀏覽器用戶端的 API 而言）

現在還不是投入正式環境的階段，但理解規格並先做準備是值得的。特別是在設計有複雜搜尋條件的 API 時，把「日後容易遷移到 QUERY 的設計」放在心上是有價值的。

## 參考連結

- [RFC 10008: The HTTP QUERY Method](https://datatracker.ietf.org/doc/html/rfc10008)
- [RFC 10008: HTTP Finally Gets a QUERY Method (Jordan Rowles)](https://jordansrowles.medium.com/rfc-10008-http-finally-gets-a-query-method-22441ae51ded)
- [Ruby on Rails: QUERY method support proposal](https://discuss.rubyonrails.org/t/proposal-support-for-the-http-query-method-rfc-10008/91255)
- [範例程式碼：`http-query-method-rfc10008-demo/`](https://github.com/oharu121/http-query-method-rfc10008-demo)
