# HTTP QUERYメソッド（RFC 10008）をPythonで実装して、GET・POSTとの違いを体感してみた

> RFC 10008のHTTP QUERYをPythonとStarletteで実装。構造化ボディを持ちながら安全・冪等・キャッシュ可能なリクエスト、そのエラーハンドリング、キャッシュ、GraphQLとの補完関係を解説します。

- Source: https://oharu121.com/ja/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** |
|------|-----|------|-----------|
| リクエストボディ | なし | あり | **あり** |
| 安全（Safe） | Yes | No | **Yes** |
| 冪等（Idempotent） | Yes | No | **Yes** |
| キャッシュ可能 | Yes | 制限的 | **Yes** |

- **GET** は安全で冪等でキャッシュ可能だが、ボディを持てない。複雑な検索条件はURLクエリ文字列に詰め込む必要があり、URLの長さ制限（実質的に約2048文字）やネスト構造の表現に苦しむ
- **POST** はボディを持てるが、「状態を変更する操作」というセマンティクスを持つ。プロキシやキャッシュは「副作用がある」と判断するため、キャッシュが効きにくく、自動リトライも安全ではない

QUERYは、GETの安全性・冪等性・キャッシュ可能性を維持しながら、POSTのように構造化されたリクエストボディを送れるメソッドです。

## Safe・Idempotent・Cacheableとは何か、なぜ重要か

上の表に登場した3つの特性について、それぞれ具体的に説明します。

### Safe（安全）

リクエストを送っても**サーバーの状態が変わらない**ことを意味します。GETでページを読むのは安全、DELETEでレコードを消すのは安全ではありません。

なぜ重要か: ブラウザ、クローラー、プリフェッチ機構は「安全なメソッド」であれば自由にリクエストを送信します。2005年にGoogle Web Acceleratorがリンクを先読みした結果、GETで偽装されたDELETE操作が発火し、ユーザーのデータが削除される事故がありました。「安全」の宣言はインフラ全体の自動化にとって不可欠なシグナルです。

*Image: 安全なGET /pageと安全ではないDELETE /users/123を比較し、ブラウザ・クローラー・プリフェッチ機構がサーバーの状態を変えないリクエストを自由に送信する様子を示す図*

### Idempotent（冪等）

同じリクエストを**1回送っても100回送っても結果が同じ**であることを意味します。GET、PUT、DELETEは冪等です。POSTは冪等ではありません（決済を2回送信すれば2回課金される可能性がある）。

なぜ重要か: ネットワーク障害時の自動リトライです。リクエストがタイムアウトした場合、冪等なメソッドであればクライアントやプロキシが自動的に再送できます。POSTの再送は安全ではないため、ブラウザは「フォームを再送信しますか？」と確認ダイアログを出します。

*Image: 冪等なGET・PUT・DELETEと、送信のたびに新しい決済を作成するPOST /paymentsを比較し、ネットワークタイムアウト後の自動リトライを示す図*

### Cacheable（キャッシュ可能）

レスポンスを保存して、同一のリクエストに対して**再利用できる**ことを意味します。

なぜ重要か: パフォーマンスです。CDNはGETレスポンスを世界中の拠点にキャッシュします。POSTのレスポンスは一般的にキャッシュされません。なぜなら、キャッシュ機構はPOSTが状態を変更する操作だと認識しているため、レスポンスが即座に古くなる可能性があるからです。QUERYはGETと同様にキャッシュ可能ですが、キャッシュキーにリクエストボディも含めるため、異なるクエリボディには異なるキャッシュエントリが使われます。

*Image: キャッシュ可能なGET、キャッシュ不可なPOST、そしてリクエストボディごとに異なるキャッシュエントリとなるQUERYをCDN上で比較した図*

## 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大手2社のエンジニアが仕様を書いたということは、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** の3つのメソッドで実行し、違いを比較します。

検索条件の例:

```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:
    """HTTPメソッドに応じてハンドラを振り分け"""
    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)
    # ... near_lat, near_lng, near_radius など個別パラメータが必要
```

ネストされた構造（`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を2回送ると副作用が2回起きる可能性がある）
- ブラウザの戻るボタンで「フォームを再送信しますか？」と聞かれるのも、POSTが安全でないことの表れ

### QUERYハンドラ: RFC 10008準拠の実装

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

    # RFC 10008 §3: Content-Typeヘッダが必須
    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: 未対応のメディアタイプには415 + 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: QUERYレスポンスはキャッシュ可能
    # キャッシュキーにリクエストボディのハッシュを含める
    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: 構文的に正しいが意味的に処理できない → 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メソッドを指定できるため、そのまま動作します。

### 1回目のレスポンス（キャッシュ 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",...}]}
```

### 2回目のレスポンス（キャッシュ 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はrequest()メソッドでカスタムHTTPメソッドをサポート
    resp = client.request(
        "QUERY",
        "/products/search",
        content=json.dumps(SEARCH_QUERY),
        headers={"Content-Type": "application/json"},
    )
    print(resp.json())
```

httpxの `client.request()` は第1引数に任意の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を得るまでの4ステップのやり取りを示す図*

## GraphQLとの関係: 競合ではなく補完

「QUERYメソッドはGraphQLと同じ問題を解こうとしているのか？」という疑問が浮かぶかもしれません。結論から言えば、**競合ではなく補完関係**です。両者は異なるレイヤーで動作します。

| | GraphQL | HTTP QUERY |
|---|---------|------------|
| **何であるか** | クエリ言語 + ランタイム | トランスポートメソッド |
| **レイヤー** | アプリケーション層（クエリの表現方法） | プロトコル層（クエリの送信方法） |
| **定義するもの** | スキーマ、型、リゾルバ、フィールド選択 | リクエストの安全性、冪等性、キャッシュ可能性 |

GraphQLは「**何を**問い合わせるか」を定義し、HTTP QUERYは「**どうやって**その問い合わせをHTTPで送るか」を定義します。

*Image: アプリケーション層のクエリ言語であるGraphQLと、プロトコル層の転送メソッドであるHTTP QUERYを対比した表と、GraphQLクエリをQUERY /graphqlで送信する流れを示す図*

### GraphQLの現在のトランスポート問題

今日のGraphQLは主にPOSTでクエリを送信しています:

```bash
# 現在のGraphQLの一般的な送信方法
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 — 理想的な組み合わせ
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**: レイヤーが異なり、組み合わせ可能 |

むしろGraphQLチームにとってQUERYは朗報です。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)
