2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【RFC 10008】新HTTPメソッド QUERY 入門 — GETのかゆいところに手が届く / FastAPIで動くのか実際に試した

2
Last updated at Posted at 2026-08-20

TL;DR

  • QUERY(RFC 10008 / 2026年6月標準化)は「ボディを持てる GET」。safe・idempotent・cacheable という GET の性質を保ったまま、リクエストボディを送れます。
  • FastAPI は動きます。 ただし @app.query() は存在せず api_route(methods=["QUERY"]) を使う必要があり、OpenAPI に requestBody が出力されないという落とし穴があります(回避策を検証済み)。
  • キャッシュは FastAPI が何もしてくれません。全部自前実装です。 ただし必須ではなく、「QUERY で受けるだけ」でメリットの大半は得られます。
  • ⚠️ 「キャッシュのために QUERY」は現時点では根拠が弱いです。インフラ側がまだ追いついていません(詳細は姉妹記事「QUERYメソッドはどこまで通るのか」に分けました)。

検証環境: Python 3.11.15 / fastapi 0.141.1 / starlette 1.6.0 / uvicorn 0.52.1 / httptools 0.8.0 / h11 0.16.0 / httpx 0.28.1
掲載しているコードと実行結果は、すべてこの環境で実際に動かして確認したものです。


1. QUERYメソッドとは

1-1. 用語のおさらい

先に用語を整理しておきます(HTTPの仕様書によく出てくる言葉です)。

用語 意味
safe(安全) そのリクエストがサーバの状態を変更しない(=読み取り専用)こと。GET / HEAD / OPTIONS などが該当
idempotent(冪等 / べきとう) 同じリクエストを何回送っても、1回送ったときと結果が同じであること。通信エラー時に安心して再送できる
cacheable(キャッシュ可能) レスポンスをキャッシュに保存して、後続の同じリクエストに再利用してよいこと
リクエストボディ リクエストのURLではなく本文にデータを載せる部分。POST や PUT で使う

1-2. 各メソッドの比較

GET POST QUERY
safe(読み取り専用)
idempotent(再送可)
cacheable 実質❌
リクエストボディ ❌(意味を持たない)
キャッシュキー URL —— URL + リクエストボディ
CORS プリフライト 不要(単純リクエスト時) 条件次第で不要 常に必要

つまり QUERY は「GET の意味論 + POST のボディ」を組み合わせたメソッドです。IANA の HTTP Method Registry にも Safe: yes / Idempotent: yes として登録されています。

RFC 10008 の著者は J. Reschke(greenbytes)、J. M. Snell(Cloudflare)、M. Bishop(Akamai)。HTTPインフラの当事者が書いている点も、実装が進むであろうことを示唆しています。

1-3. 最小の例

QUERY /articles HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/json

{
  "keyword": "HTTP",
  "tags": ["network", "rfc"],
  "published_at": { "from": "2026-01-01", "to": "2026-08-01" },
  "sort": [{ "field": "likes", "order": "desc" }],
  "page": 1,
  "per_page": 20
}
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=60
Content-Location: /articles/results/9f2c1a

{ "total": 128, "items": [ ... ] }

2. QUERYのメリット

2-1. GETのかゆいところに手が届く

現場で「検索APIをGETで作る」とぶつかる壁を並べてみます。

(a) URLの長さ制限にぶつかる

HTTPの仕様上は「URLの長さに上限はないが、実装は最低 8000 バイトはサポートすべき」とされています。しかし現実には、

  • ブラウザ・リバースプロキシ・WAF・ロードバランサ・アクセスログ基盤

それぞれが独自の上限を持っており、どこで切られるか分からないのが実情です。ファセット検索(複数条件の絞り込み)や、条件をネストしたクエリDSLを投げようとすると、あっという間に数KBに到達します。

(b) 構造化データをクエリ文字列に押し込むのがつらい

JSONのようなネストした構造を、クエリ文字列(?key=value&...)で表現しようとすると、こうなりがちです。

GET /articles?filter[0][field]=tag&filter[0][op]=in&filter[0][value][]=network
             &filter[0][value][]=rfc&filter[1][field]=published_at
             &filter[1][op]=between&filter[1][value][0]=2026-01-01 ...
  • 配列・ネストの表現方法に標準がないa[]=1&a[]=2 / a=1,2 / a=1&a=2 などフレームワーク依存)
  • URLエンコード地獄でデバッグしづらい
  • サーバ側で自前のパーサを書くはめになり、型検証も自前になる

QUERY ならボディに素直な JSON を置けるので、Pydantic などのスキーマ検証がそのまま使えます。これが一番の実利かもしれません。

(c) GET にボディを付けるのは「規格上グレー」

「じゃあGETにボディを付ければ?」はよくある発想ですが、HTTPの仕様上 GET のリクエストボディには定義された意味がありません。そのため、

  • 中間装置がボディを黙って捨てる可能性がある
  • Swagger UI / OpenAPI ツール・多くのHTTPクライアントがそもそも送れない・表現できない
  • 実装によっては 400 を返す

という不安定さが残ります。QUERY はこの用途を正式に定義したメソッドです。

(d) 検索条件がURLに残ってしまう(機密情報の観点)

これは業務システムでは特に重要です。GETの場合、検索条件は URL に載るため、

  • アクセスログ・プロキシログ・CDNログ
  • ブラウザの履歴、ブックマーク
  • Referer ヘッダ経由での外部サイトへの漏洩

そのまま残り続けます。氏名・生年月日・各種ID・患者情報といった個人情報を検索条件にするAPIでは、これは事故のもとです。

QUERY はボディに条件を置くため、標準的なアクセスログには残りません。

⚠️ ただし 「QUERYにすれば安全」ではありません。ボディはTLSで保護されますが、アプリケーションログにリクエストボディを出力していれば同じことです。また、後述するキャッシュはボディを含めて保存するため、キャッシュに個人情報が残るという新しい論点も生まれます。個人情報を含む検索は、ログ設計・キャッシュ設計とセットで検討してください。

2-2. なぜPOSTじゃだめなのか

「複雑な検索は POST /search でいいのでは?」——実際そう作られたAPIは山ほどありますし、動きます。動きますが、HTTPの利点を捨てています。

(a) POSTは「安全でない」=キャッシュできない

POST は「サーバの状態を変更しうる」メソッドです。したがって中間のキャッシュ(CDN、リバースプロキシ、ブラウザ)は原則としてレスポンスをキャッシュしません。

同じ検索条件で1日に10万回叩かれるAPIでも、毎回オリジンサーバまで届いて毎回DBを叩くことになります。QUERY なら、仕様上エッジでキャッシュできる余地が生まれます。

⚠️ ここは「仕様上の話」で、実装はまだ追いついていません。 現状の既存インフラは POST のボディキャッシュにしか対応していません(→ 4-2)。キャッシュだけを理由に QUERY を選ぶと、いま導入する根拠としては弱いです。

(b) POSTは冪等でない=自動リトライできない

ネットワークが切れて応答が返ってこなかったとき、

  • POST:リトライしていいか分からない(二重に処理される恐れがある)→ クライアント・プロキシは再送しない
  • QUERY:冪等なので安全に再送できる

検索API程度なら「アプリ側でリトライすればいい」と思うかもしれませんが、それはあなたのクライアントだけの話です。HTTPクライアントライブラリ、サービスメッシュ、プロキシといった汎用的な層が自動でリトライしてくれるかどうかは、メソッドの意味論で決まります

(c) 「読み取り」と「書き込み」をメソッドで判別できなくなる

POST /search を採用すると、インフラ・運用の各レイヤで区別がつかなくなります。

レイヤ POSTで検索した場合の困りごと
WAF / セキュリティ監視 検索も更新も同じ「POST」。異常検知のベースラインが引きにくい
監査ログ 「誰が何を書き換えたか」を抽出するときに、検索リクエストがノイズになる
読み書き分離(DBルーティング) メソッドでリードレプリカに振り分けられず、パス名で個別設定が必要
レートリミット 読み取りと書き込みで別の閾値を設けたいのに、パス単位の設定が増える
リトライポリシー サービスメッシュ(Envoy等)の「idempotentなら再送」設定が効かない

QUERY を使うと、これらがメソッド名だけで機械的に判定できるようになります。「メソッドは意味論を運ぶメタデータである」という、HTTP本来の設計思想に戻る、というのが本質です。

(d) POST が「間違い」なわけではない

公平に書いておくと、POST /search は今も現実的な選択肢です。RFC 10008 自体、POSTを禁止するものではありません。判断基準はこのあたりです。

  • QUERYが向く:検索・集計・GraphQL的なクエリなど、明確に読み取り専用で、キャッシュ効果が見込め、条件が長い/構造化されている
  • POSTのままでよい:副作用がある(検索履歴の保存、課金カウント等)、社内限定でトラフィックも小さい、インフラが古くて通らない

3. PythonのFastAPIでQUERYは使えるのか

3-1. 結論

項目 状況
ルーティング(Starlette) ✅ 動く。メソッド名のホワイトリストは存在せず、渡された文字列を大文字化して使うだけ
ASGIサーバ uvicorn(httptools) ✅ 動く
ASGIサーバ uvicorn(h11) ✅ 動く
リクエストボディのパース・Pydantic検証 ✅ 動く
@app.query(...) 専用デコレータ 存在しない@app.api_route を使う)
OpenAPIスキーマの requestBody 出力されない(要回避策・後述)
OpenAPI 3.2(query / additionalOperations 未対応。最新版でも未実装で、バージョンを上げても解消しない
TestClient でのテスト ✅ 動く(client.request("QUERY", ...)
httpx クライアント ✅ 動く(httpx.request("QUERY", ...)

FastAPI のリポジトリには QUERY 対応を求める Issue(#5792#12965)がありますが、いずれもクローズ済みで、専用デコレータは追加されていません。かつては「httptools が QUERY を解釈できない」という下位レイヤの制約が語られていましたが、現在は解消されており、そのままで動作します

3-2. 動くコード

# app.py
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class SearchQuery(BaseModel):
    keyword: str
    page: int = 1


# @app.query は存在しないので api_route を使う
@app.api_route("/search", methods=["QUERY"])
def search(q: SearchQuery):
    return {"got": q.model_dump()}

起動して叩いてみます。

$ uvicorn app:app --port 8011 --http httptools

$ curl -s -X QUERY http://127.0.0.1:8011/search \
    -H 'Content-Type: application/json' \
    -d '{"keyword":"hoge","page":2}'
{"got":{"keyword":"hoge","page":2}}

サーバログにもきちんと出ます。

INFO:     127.0.0.1:51238 - "QUERY /search HTTP/1.1" 200 OK

--http h11 に切り替えても同じ結果でした。HTTPパーサの選択に関係なく動きます。

未対応メソッドで叩けば、Allow ヘッダ付きの 405 が返ります。これも期待どおりです。

$ curl -s -i -X GET http://127.0.0.1:8011/search | head -3
HTTP/1.1 405 Method Not Allowed
date: Thu, 13 Aug 2026 00:05:30 GMT
server: uvicorn
allow: QUERY

3-3. なぜ動くのか(Starletteの実装)

Starlette の routing.py を見ると、メソッドの扱いはこれだけです。

# starlette/routing.py(抜粋)
if methods is None:
    self.methods = None
else:
    self.methods = {method.upper() for method in methods}
    if "GET" in self.methods:
        self.methods.add("HEAD")

既知のメソッド一覧との照合が一切ありません。 渡された文字列を大文字化して集合に入れ、scope["method"] と突き合わせるだけ。だから QUERY でも、それこそ独自メソッドでもそのまま通ります。「対応した」というより「もともと素通しだった」というのが正確です。

3-4. クライアント側

# httpx
import httpx

r = httpx.request(
    "QUERY",
    "http://127.0.0.1:8011/search",
    json={"keyword": "abc", "page": 3},
)
print(r.status_code, r.text)
# -> 200 {"got":{"keyword":"abc","page":3}}
# テストコード(TestClient)
from fastapi.testclient import TestClient
from app import app

client = TestClient(app)


def test_search():
    r = client.request("QUERY", "/search", json={"keyword": "t", "page": 9})
    assert r.status_code == 200
    assert r.json() == {"got": {"keyword": "t", "page": 9}}

client.query(...) のようなショートカットはないので、client.request("QUERY", ...) を使います。

3-5. ⚠️ 落とし穴:OpenAPIに requestBody が出ない

ここが実務で一番ハマるところです。上記の app.py が生成する OpenAPI スキーマを見てみます。

$ curl -s http://127.0.0.1:8011/openapi.json | python -m json.tool
{
  "openapi": "3.1.0",
  "paths": {
    "/search": {
      "query": {
        "summary": "Search",
        "operationId": "search_search_query",
        "responses": { "200": { ... }, "422": { ... } }
      }
    }
  }
}

requestBody が存在しません。 実行時には正しくボディをパースして Pydantic 検証まで通しているのに、スキーマ上は「ボディを取らないエンドポイント」に見えてしまいます。当然、ドキュメントからスキーマを見た人も、スキーマからクライアントを自動生成するツールも、ボディの存在に気づけません。

原因は FastAPI 内部の定数です。

# fastapi/openapi/constants.py
METHODS_WITH_BODY = {"GET", "HEAD", "POST", "PUT", "DELETE", "PATCH"}
# fastapi/openapi/utils.py(抜粋)
if method in METHODS_WITH_BODY:
    # ここで requestBody を生成する

"QUERY" がこの集合に入っていないため、requestBody の生成処理がスキップされます。

回避策(検証済み)

集合に "QUERY" を追加すれば解決します。ただし utils.pyfrom ... import METHODS_WITH_BODY値をモジュール変数として取り込み済みなので、そちらの参照も差し替える必要があります。

# app.py
from fastapi import FastAPI
from fastapi.openapi import constants
import fastapi.openapi.utils as openapi_utils
from pydantic import BaseModel

# --- QUERY を「ボディを持つメソッド」として登録する ---
constants.METHODS_WITH_BODY.add("QUERY")
openapi_utils.METHODS_WITH_BODY = constants.METHODS_WITH_BODY
# ----------------------------------------------------

app = FastAPI()


class SearchQuery(BaseModel):
    keyword: str
    page: int = 1


@app.api_route("/search", methods=["QUERY"])
def search(q: SearchQuery):
    return {"got": q.model_dump()}

これで requestBody が出力されるようになります。

"requestBody": {
  "required": true,
  "content": {
    "application/json": {
      "schema": { "$ref": "#/components/schemas/SearchQuery" }
    }
  }
}

⚠️ これは FastAPI の内部定数に手を入れるモンキーパッチです。将来のバージョンで内部構造が変われば壊れます。導入する場合は、この挙動を検知するテスト(openapi.jsonrequestBody が含まれることを確認する assert)を必ず添えてください。

3-6. ⚠️ もうひとつの落とし穴:OpenAPIのバージョン(FastAPIを上げても直らない)

上の出力をよく見ると、"openapi": "3.1.0" の下に paths./search.query というキーが出ています。しかし OpenAPI 3.1 の Path Item Object に query フィールドは定義されていません(固定フィールドは get / put / post / delete / options / head / patch / trace のみ)。query と、任意のメソッドを書ける additionalOperations が追加されたのは OpenAPI 3.2.0(2025年9月) です。

つまり FastAPI が出力しているのは、3.1 としては仕様外のドキュメントです。バリデータやコードジェネレータがこれをどう扱うかはツール依存になります。

「FastAPIを新しくすれば直るのでは?」→ 直りません

切り分けて検証しました。

① 使っている FastAPI は最新版でしたpip index versions fastapi0.141.1)。古いから未対応、ではありません。

② FastAPI 側が OpenAPI 3.2 に未対応です。 fastapi/openapi/models.pyPathItemget / put / post / delete / options / head / patch / trace だけで、queryadditionalOperations もありません。パッケージ全体を grep しても additionalOperations は0件でした。paths./search.query というキーが出るのは、生成処理がこのモデルを経由せず素の dict に path[method.lower()] = operation と代入しているからで、意図した3.2対応ではなく素通りしているだけです。

openapi_version の変更は公式に「ハック」と明記されています。

FastAPI will generate OpenAPI version 3.1.0 ... So you could override this value to trick those tools into using the generated OpenAPI. Have in mind that this is a hack.
fastapi/applications.py の docstring

実際に app.openapi_version = "3.2.0" を設定した結果がこれです。

openapi              : 3.2.0        ← 文字列だけ変わる
path item keys       : ['query']    ← additionalOperations にはならない
requestBody          : False        ← 直らない

バージョン文字列が変わるだけで、生成ロジックは 3.1.0 のまま。 「3.2 を名乗る 3.1 文書」になり、バリデータを通す観点ではむしろ悪化します。

見通し

FastAPI が長く QUERY を見送ってきた理由は「OpenAPI 側がブロッカー(We are blocked by OpenAPI here)」でした。OpenAPI 3.2.0 でそれは解消され、Discussion #15959 で再燃していますが、今度は Swagger UI が新メソッドを描画できない点で停滞中です。ネイティブ対応(@app.query())が入るまでは、3-5 のモンキーパッチで凌ぐことになります。

なお 3-5 の requestBody が出ない問題は、OpenAPI のバージョンとは無関係な別問題です。原因は METHODS_WITH_BODY"QUERY" が無いことだけなので、切り分けて考えてください。


4. レスポンスキャッシュについて

QUERY の最大の売りがキャッシュなので、ここは丁寧に見ていきます。

4-1. 仕様上どう決まっているか

RFC 10008 の要点は次のとおりです。

  1. QUERY のレスポンスはキャッシュ可能であり、キャッシュは後続の QUERY リクエストに対してそれを再利用してよい。
  2. キャッシュキーには、リクエストのコンテンツ(ボディ)と関連メタデータを組み込まなければならない(MUST)。
  3. キャッシュ効率を上げるため、キャッシュは意味が変わらない範囲の差分をあらかじめ取り除いてよい(MAY)。例えば、コンテンツエンコーディングを解いてから比較する、Content-Type のサフィックス(+json など)で形式が分かるならその流儀で正規化する、といった処理です。
  4. QUERY のキャッシュは GET より本質的に複雑である。キャッシュキーを決めるためにリクエストボディを最後まで読み切る必要があるため。

3番目が実務的に効いてきます。JSON なら、

{"keyword":"HTTP","page":1}
{ "page": 1, "keyword": "HTTP" }

意味的に同じです。素朴にバイト列でキーを作ると別物として扱われ、キャッシュヒット率が落ちます。JSONとして正規化(キーのソート、空白の除去)してからハッシュを取る、というのが仕様の示唆する方向です。ただしこれは MAY(してもよい)であって必須ではありません — 詳しくは 4-3 で実測しながら説明します。

なお 2番の MUST は「キャッシュを実装する側」への要求です。「QUERY を使うならキャッシュを実装しなければならない」という意味ではありません。ここは次項で詳しく切り分けます。

4-2. 【重要】どこまでが自動で、どこからが自前実装か

ここが一番誤解しやすいところなので、先に結論を置きます。

FastAPI は QUERY について、キャッシュもヘッダも一切自動ではやりません。全部自前実装です。

RFC 10008 が定めているのは「サーバはこう返してもよい / キャッシュを実装するならこう作れ」という取り決めであって、フレームワークが勝手にやってくれるものではありません。役割分担を整理します。

やること 誰がやる 必須か
QUERY のルーティング FastAPI が自動
リクエストボディのパース・Pydantic検証 FastAPI が自動
Cache-Control を返す 自分で書く キャッシュさせたいなら必要
キャッシュのヒット判定・保存 自分で書く(本来はCDN等の役目だが現状未対応) キャッシュしたいなら必要
キャッシュキーにボディを含める 自分で書く キャッシュを実装するなら必須(MUST)
JSONの正規化(キー順・空白の吸収) 自分で書く 任意(MAY)。ヒット率の最適化
Location / Content-Location を返す 自分で書く 任意(MAY)
↑ そのURLに対応する GET エンドポイント 自分で書く ヘッダを返すなら必須

素の FastAPI が実際に返すもの

3-2 のコード(キャッシュ処理なし)に QUERY を投げて、レスポンスヘッダを全部出してみました。

--- 素のFastAPIが返すレスポンスヘッダ ---
  content-length: 32
  content-type: application/json

location          : (なし)
content-location  : (なし)
cache-control     : (なし)
etag              : (なし)
vary              : (なし)
age               : (なし)

--- 同じリクエストを2回投げたとき ---
2回目にキャッシュ由来のヘッダは付くか: (付かない=毎回ハンドラが実行されている)

何も付きません。 同じ条件を2回投げても、2回ともハンドラが実行されます。QUERY にしただけでは、キャッシュの恩恵は1ミリも発生しません。

「キャッシュキーにボディを含めなければならない(MUST)」は誰への要求か

これはキャッシュを実装する主体への要求です。本来はブラウザ・CDN・リバースプロキシがその主体になるはずですが——

2026年8月時点で、それらは QUERY のキャッシュを実装していません。 nginx は proxy_cache_methods QUERY が設定エラーで起動すらせず、Apache httpd の mod_cache は GET のみ、ブラウザも未実装です。実測とソースで確認した各製品の状況は、長くなるので姉妹記事 「QUERYメソッドはどこまで通るのか」 に分けました。

つまり、いま QUERY のキャッシュを効かせたければ、あなたのアプリがキャッシュ実装主体になるしかなく、その場合この MUST はあなたに掛かってきます。 逆に「キャッシュはしない、まずは POST からの置き換えだけやりたい」なら、キャッシュ実装はまるごと不要です。QUERY を使うこと自体にキャッシュ実装は必須ではありません。

4-3. 3つの実装レベル

「どこまでやるか」は段階的に選べます。無理に全部やる必要はありません。

レベル やること 得られるもの
Lv.1 QUERY で受けるだけ(3-2のコードのまま) URL長制限からの解放、条件がURLに残らない、メソッドで読み書きを判別できる
Lv.2 Lv.1 + アプリ層で自前キャッシュ 同一条件の再検索でDBを叩かなくなる
Lv.3 Lv.2 + Location / Content-Location + GETエンドポイント 2回目以降を GET に逃がし、既存のCDN・ブラウザキャッシュに乗せられる

まずは Lv.1 で十分です。 メリットの大半(2-1で挙げたGETのつらみの解消、2-2のPOST問題の解消)は Lv.1 だけで得られます。

JSONの正規化はやらないとどうなるか(=任意である理由)

これは「やらなくても正しく動くが、ヒット率が落ちる」だけの最適化です。実際に差を測ってみます。

import hashlib, json

a = b'{"keyword":"HTTP","page":1}'
b = b'{ "page": 1, "keyword": "HTTP" }'   # 意味は同じ/並びと空白が違う
正規化なし:  0b66551578fd  69c366cf9a0f  -> MISS(別物扱い)
正規化あり:  0b66551578fd  0b66551578fd  -> HIT

正規化しないと、意味的に同じ検索が別のキャッシュエントリになります。クライアントが1種類(自社のフロントだけ)でリクエストの組み立て方が固定なら、実害はほぼありません。逆に複数のクライアントや外部利用者がいるなら、入れておく価値があります。判断はあなたの状況次第で、必須ではありません。

4-4. LocationContent-Location の使い分け(Lv.3)

QUERY のレスポンスには、2つの異なるURIを返せます(どちらも任意)。混同しやすいので表にします。

ヘッダ 指すもの クライアントがそのURIにGETすると
Content-Location この検索の「結果」 いま返ってきたのと同じ結果そのものが取得できる(スナップショット)
Location この検索と等価な「リソース」 同じ検索が再実行される(保存された検索条件。ボディを再送しなくてよい)
HTTP/1.1 200 OK
Content-Type: application/json
Content-Location: /searches/9f2c1a/results   ← この結果のスナップショット
Location: /searches/9f2c1a                    ← 検索条件そのもの(叩くと再検索)
Cache-Control: max-age=300

⚠️ これらのヘッダは自分でセットするものです。そして、返すからには宛先の GET エンドポイントも自分で実装しなければなりません。 404 になるURLを案内するのは、返さないより有害です。

これを実装すると何が嬉しいかというと、2回目以降をふつうの GET に逃がせるからです。

  • Location を返す → クライアントは以降 GET /searches/9f2c1a を使える。URLだけでキャッシュキーが決まるので、QUERY 未対応の既存CDN・ブラウザキャッシュがそのまま効く
  • Content-Location を返す → 結果が固定されたスナップショットURL。ページネーションや共有リンクに使える

つまり「初回は QUERY、以降は GET」という設計です。CDNが QUERY を理解できない現状では、これが一番実効性のある逃げ道になります。

設計上の宿題:Location を返す方式は「保存された検索」をサーバ側に作ることになります。**保持期間(いつ消すか)とアクセス制御(他人の検索結果が見えないか)**の設計が必須です。特に検索条件に個人情報が含まれる場合、推測可能なIDにしない・認可チェックを必ず通す、といった配慮が要ります。

4-5. Lv.3 まで実装した完全なサンプル(動作検証済み)

ここまでの話を全部入れた、そのまま動くコードです。

import hashlib
import json

from fastapi import FastAPI, HTTPException, Request, Response

app = FastAPI()

# 実運用では Redis 等の外部キャッシュに置き換える
_results: dict[str, bytes] = {}   # キャッシュキー -> 検索結果
_queries: dict[str, bytes] = {}   # キャッシュキー -> 検索条件(再検索用)


def make_cache_key(path: str, body: bytes, content_type: str) -> tuple[str, bytes]:
    """RFC 10008 に沿ったキャッシュキーと、正規化後のボディを返す。

    パス + リクエストボディからキーを導出する(ボディを含めるのが MUST)。
    JSON の場合はキー順・空白の違いを吸収するため正規化してからハッシュ化する
    (仕様が MAY として認めている「意味的に有意でない差分の除去」/任意)。
    """
    normalized = body
    if content_type.split(";")[0].strip().endswith(("json", "+json")):
        try:
            normalized = json.dumps(
                json.loads(body), sort_keys=True, separators=(",", ":")
            ).encode()
        except json.JSONDecodeError:
            pass  # 壊れたJSONは正規化せず生バイト列のまま扱う

    h = hashlib.sha256()
    h.update(path.encode())
    h.update(b"\0")
    h.update(normalized)
    return h.hexdigest()[:12], normalized


def run_search(cond: dict) -> dict:
    """実際の検索処理(ここではダミー)"""
    return {"keyword": cond.get("keyword"), "hits": 3, "items": ["a", "b", "c"]}


@app.api_route("/search", methods=["QUERY"])
async def search(request: Request):
    body = await request.body()          # ← キーを決めるにはボディを読み切る必要がある
    key, normalized = make_cache_key(
        request.url.path, body, request.headers.get("content-type", "")
    )

    cached = _results.get(key)
    if cached is None:
        cached = json.dumps(run_search(json.loads(normalized))).encode()
        _results[key] = cached
        _queries[key] = normalized       # Location 用に検索条件も保存しておく
        x_cache = "MISS"
    else:
        x_cache = "HIT"

    return Response(
        content=cached,
        media_type="application/json",
        headers={
            "X-Cache": x_cache,
            "Cache-Control": "max-age=60",
            "Location": f"/searches/{key}",                   # 叩くと再検索
            "Content-Location": f"/searches/{key}/results",   # この結果のスナップショット
        },
    )


@app.get("/searches/{key}")            # Location の宛先:再検索する
async def rerun(key: str):
    cond = _queries.get(key)
    if cond is None:
        raise HTTPException(404)
    fresh = json.dumps(run_search(json.loads(cond))).encode()
    _results[key] = fresh
    return Response(fresh, media_type="application/json",
                    headers={"Cache-Control": "max-age=60"})


@app.get("/searches/{key}/results")    # Content-Location の宛先:保存済み結果
async def snapshot(key: str):
    cached = _results.get(key)
    if cached is None:
        raise HTTPException(404)
    return Response(cached, media_type="application/json",
                    headers={"Cache-Control": "max-age=300", "X-Cache": "HIT"})

動作を確認します。

from fastapi.testclient import TestClient
c = TestClient(app)

r1 = c.request("QUERY", "/search", json={"keyword": "HTTP", "page": 1})
r2 = c.request("QUERY", "/search", json={"keyword": "HTTP", "page": 1})
# キーの順番と空白を変えた、意味的には同じリクエスト
r3 = c.request("QUERY", "/search",
               content=b'{"page":1,"keyword":"HTTP"}',
               headers={"content-type": "application/json"})
1回目          : 200 X-Cache= MISS
  Location        : /searches/e28ddb788823
  Content-Location: /searches/e28ddb788823/results
2回目(同一)    : 200 X-Cache= HIT
3回目(キー順違): 200 X-Cache= HIT  <- 正規化が効いている

Location へ GET        : 200
Content-Location へ GET: 200

このコードは全行が自前実装です。 FastAPI が肩代わりしてくれるのは、@app.api_route(..., methods=["QUERY"]) でルーティングし request.body() を渡すところまで。それ以外——キャッシュのヒット判定も、Cache-Control も、Location / Content-Location も、その宛先の2つの GET エンドポイントも——すべて自分で書いています。

💡 キャッシュに個人情報を含む検索条件が入りうる場合は、Cache-Control: private の付与、キャッシュのTTLを短くする、認可主体をキャッシュキーに含める(他ユーザーの結果が漏れないようにする)といった対策を必ず検討してください。QUERY のキャッシュキーはボディを含むため、キャッシュストア自体が個人情報の保管場所になりえます。上のサンプルはこの点を考慮していないので、そのまま本番に持ち込まないでください。


5. 導入戦略:段階的に入れる

いきなり全面移行はリスクがあるので、こういう順序をおすすめします。

Step 1. 既存のPOSTを残したまま、QUERYを追加で受ける

@app.api_route("/search", methods=["QUERY", "POST"])
def search(q: SearchQuery):
    return run_search(q)

Starlette は複数メソッドをそのまま受けられるので、これだけで両対応になります。既存クライアントは POST のまま、新しいクライアントから QUERY を使い始められます。

Step 2. クライアント側でフォールバックする

QUERY が経路のどこかで弾かれると 405 が返ります。クライアント側で拾ってPOSTに落とす実装を入れておくと安全です。

import httpx


def search(client: httpx.Client, payload: dict) -> httpx.Response:
    r = client.request("QUERY", "/search", json=payload)
    if r.status_code == 405:
        # 経路上のどこかが QUERY 未対応 → POST にフォールバック
        r = client.post("/search", json=payload)
    return r

Step 3. 経路上の全ノードを確認する

クライアントとオリジンの間にいる全員が QUERY を通す必要があります。

  • ロードバランサ(ALB / NLB / L7プロキシ)
  • CDN
  • リバースプロキシ(nginx / Envoy)
  • WAF(未知のメソッドをブロックする設定になっていないかが要注意ポイント)
  • API Gateway(許可メソッドのホワイトリスト設定があることが多い)

WAFやAPI Gatewayが「既知のメソッドだけ許可」という設定になっていると、ここで落ちます。段階導入の前に、まず疎通確認だけ先にやっておくのが確実です。

Step 4. Location / Content-Location でGETに逃がす

キャッシュの恩恵を最大化する段階です。前述のとおり、2回目以降を GET にできれば既存のキャッシュ基盤に乗ります。


6. まとめ

  • QUERY は「ボディを持てる安全なGET」。 URL長制限・構造化データの表現・検索条件がURLに残る問題を、正面から解決します。
  • FastAPI は動きます。 @app.api_route(path, methods=["QUERY"]) を使ってください。ただし OpenAPI の requestBody が出力されないので METHODS_WITH_BODY への追加が必要で、OpenAPI 3.2 対応は最新版でも未実装(バージョンを上げても解消しません)。
  • キャッシュは全部自前実装です。 Cache-ControlLocation / Content-Location も、その宛先の GET エンドポイントも、ヒット判定も自分で書きます。ただし全部やる必要はありません。「QUERY で受けるだけ」(Lv.1)でメリットの大半は取れます。
  • ⚠️ 「キャッシュのために QUERY」は現時点では根拠が弱い、というのが実測した正直な結論です。GET に逃がすパターンは POST でも組めますし、インフラ側のキャッシュ対応が追いついていません。**QUERY を選ぶ理由は「安全・冪等であることを経路上の全員に宣言できること」**に置くべきです。
  • 導入は POSTと併記 → クライアント側405フォールバック → 経路確認 → GETへの誘導、の順で段階的に。

「新しいHTTPメソッドが標準化される」のは2010年代以降ほとんどなかった出来事です。検索APIを POST /search と書き続けてきた身としては、ようやく正しい名前がついた、という感慨があります。


参考リンク

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?