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

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

2026年6月に標準化されたHTTP QUERYメソッド(RFC 10008)をPythonで実装し、GET・POSTとの違いを実際に動かして比較しました。ボディ付きで安全・冪等・キャッシュ可能な新メソッドの仕様と実装ポイントを解説します。
2026.06.26

はじめに

2026年6月、新しいHTTPメソッド QUERYRFC 10008 として正式に標準化されました。GETとPOSTの「いいとこ取り」をするこのメソッド、実際にPythonでサーバーを実装して動かしてみました。

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

TL;DR

http-query-method-rfc10008-python-demo-method-comparison

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操作が発火し、ユーザーのデータが削除される事故がありました。「安全」の宣言はインフラ全体の自動化にとって不可欠なシグナルです。

e02de4d5-cc9c-4eda-972b-d47d4189fdba

Idempotent(冪等)

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

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

02590137-784d-452f-8496-ff82977d5411

Cacheable(キャッシュ可能)

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

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

a849dc46-dbda-45a6-9a07-860ae1129f03

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を流用し、キャッシュ可能性を犠牲にしていました。

2f6a74f0-5fe0-494b-8dcf-14f6a8bedd99

なぜ今まで存在しなかったのか

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つのメソッドで実行し、違いを比較します。

検索条件の例:

{
  "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 ソート条件。対象フィールドと昇順/降順をネストで指定

注目すべきは、pricenearsort がネストされたオブジェクトである点です。これらをGETのクエリ文字列で表現しようとすると、price_min=500&price_max=2000&near_lat=35.68&near_lng=139.76&near_radius=1.0 のようにフラットに展開する必要があり、構造が失われます。フィールド数が増えるほどURLは長くなり、実質的な上限(約2048文字)にすぐ到達します。

05dac2b2-fcc1-4b93-9de5-10f2f018a938

サーバー実装

プロジェクトセットアップ

# 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",
]
uv sync
uv run uvicorn server:app --reload

QUERYメソッドのルーティング

2026年6月時点では、ほとんどのWebフレームワークがQUERYメソッドをネイティブサポートしていません。Starletteでは Routemethods パラメータにカスタムメソッド名を渡すことで対応できました。

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の Routemethods リストに任意の文字列を受け付けてくれることです。フレームワーク側で明示的に「QUERY」をサポートしているわけではなく、未知のメソッド名を受け入れてくれた、という形です。

GETハンドラ: フラットなクエリ文字列の限界

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ハンドラ: 動くが、セマンティクスが間違っている

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準拠の実装

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 メディアタイプは正しいが中身が不正

4d9e4b71-eeb6-4b71-a4b6-90d380bf12e0

キャッシュの実装

QUERYの最大の優位点はキャッシュ可能性です。GETと違い、キャッシュキーにはURIだけでなくリクエストボディも含める必要があります。

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リクエストを送信

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)

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では明確に定義されています。

68105e07-7cd9-4c75-bf11-9410963cb14a

CORSの影響

QUERYはCORSのセーフリストに含まれていないため、ブラウザからの送信時はプリフライトリクエスト(OPTIONS)が必要になります。

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

これはブラウザクライアントでのパフォーマンスに影響する可能性があります(追加の往復通信が発生)。

5b1bf509-8a2d-4a7b-927d-f397a33a7462

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

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

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

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

991236dc-8b87-4c81-b0b2-63f2f34b4d3d

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

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

# 現在の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メソッドで送信すれば、両方の利点を得られます:

# 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 — レイヤーが異なり、組み合わせ可能

98a8aa01-7dc1-4732-a99e-27d430f547da

むしろGraphQLチームにとってQUERYは朗報です。GraphQL自体を何も変えずに、HTTPトランスポート層のキャッシュ問題が解決する可能性があるからです。

フレームワーク対応状況(2026年6月時点)

フレームワーク QUERYサポート 備考
Starlette カスタムメソッドとして可 methods=["QUERY"] で動作
Express.js app.query() は未実装 app.all() + 手動判定で対応可能
FastAPI Starlette経由で可 ネイティブデコレータは未対応
Spring Boot カスタムアノテーション要 @RequestMapping(method="QUERY")
Ruby on Rails 議論中 提案がフォーラムに出ている

フレームワーク、リバースプロキシ、APIゲートウェイ、CDN、WAFのすべてが対応しないと、本番環境での利用は難しい状況です。ただし、仕様の共著者がCloudflareとAkamaiのエンジニアである点は、CDNレベルのサポートが早期に実現する可能性を示唆しています。

まとめ

RFC 10008のHTTP QUERYメソッドは、長年の「検索APIにはPOSTを使うしかない」というワークアラウンドに対する正式な解答です。

QUERYが解決すること:

  • GETでは不可能だった構造化されたリクエストボディの送信
  • POSTで失われていた安全性・冪等性・キャッシュ可能性の回復
  • Accept-Queryヘッダによる明示的なコンテンツネゴシエーション

現時点での制約:

  • フレームワークのネイティブサポートはほぼない(カスタムメソッドとして対応可能なものは多い)
  • CDN・プロキシ・WAFのサポートはこれから
  • CORSプリフライトが必要(ブラウザクライアント向けAPI)

今すぐ本番投入するフェーズではありませんが、仕様を理解して備えておく価値はあります。特に、複雑な検索条件を持つAPIを設計する際は「将来QUERYに移行しやすい設計」を意識しておくとよいでしょう。

参考リンク

この記事をシェアする

関連記事