HTTP QUERYメソッドはGETとPOSTの隙間を埋めるのか
目次
2026年6月、HTTPに新しいメソッドQUERYが加わった。
RFC 10008: The HTTP QUERY MethodとしてProposed Standardになり、IANAのHTTP Method RegistryにもSafe: yes、Idempotent: yesで登録されている。
QUERYは「リクエストボディを持てる、読み取り専用の問い合わせ」を表すメソッドだ。
GETのように安全(safe)かつ冪等(idempotent。同じリクエストを何度送っても結果が変わらない性質)で、POSTのようにボディを送れる。
いままでPOST /searchやPOST /graphqlで書いてきた「本文付きの読み取り問い合わせ」に、HTTPとしての名前が付いたことになる。
GETで足りなくなるところ
小さい検索条件ならGETでいい。
GET /feed?q=foo&limit=10&sort=-published HTTP/1.1
Host: example.org
安全で冪等、キャッシュしやすく、URLとして条件ごと共有できる。
問題は、問い合わせ条件が大きくなったときだ。
検索UIのフィルタ、ネストしたAND/OR、全文検索、ソート、ページング、集計条件が重なると、URLはすぐ長くなる。
RFC 10008も、URIに載せるデータが大きすぎる場合の問題として、経路上のサイズ制限が事前に分かりにくいこと、URIエンコードのオーバーヘッド、URIがログやブックマークに残りやすいことを挙げている。
GETにボディを付ける手もあるが、RFC 9110ではGETリクエストの本文に一般に定義された意味がない。
クライアントは、オリジンサーバーが明示的に対応している場合を除き、GET本文を生成すべきではないともされている。
途中にプロキシ、CDN、ロードバランサ、フレームワークが入ると、本文を捨てる、拒否する、想定外に扱う、という実装差が出る。
POSTだと中間層に意味が伝わらない
現実のAPIでは、こういう形がよく使われる。
POST /feed HTTP/1.1
Host: example.org
Content-Type: application/json
{
"q": "foo",
"limit": 10,
"sort": "-published"
}
ボディにJSONを置けて、URL長の制限も気にしなくていい。
ただしHTTPとしてのPOSTは「対象リソースに、リクエスト本文をそのリソース固有の意味で処理させる」メソッドで、安全でも冪等でもない。
アプリケーション側で「このPOST /searchは読み取り専用」と決めることはできる。
でも、それはそのAPIを知っている人間とサーバーだけの約束だ。
キャッシュ、プロキシ、リトライ制御、CORS、APIゲートウェイ、WAFといった中間のHTTPコンポーネントは、レスポンスが返る前にリクエストの扱いを決める。
POST /searchに200 OKが返ってきても、それが読み取り専用の検索だったのか、サーバー状態を変更する処理だったのかは、HTTPレイヤーからは区別が付かない。
ここでQUERYが入る。
QUERY /feed HTTP/1.1
Host: example.org
Content-Type: application/json
{
"q": "foo",
"limit": 10,
"sort": "-published"
}
RFC 10008は、QUERYを安全かつ冪等なメソッドとして定義している。
リクエスト本文とContent-Typeなどのメタデータが問い合わせ内容を定義し、対象URIが問い合わせのスコープを決める。
POSTと同じように本文を送れるが、「これは読み取り専用で、再試行できる問い合わせだ」という意味がメソッドから読み取れる。
QUERYが定義するもの
QUERYが定義するのはHTTPレベルの意味だけで、問い合わせ言語には踏み込まない。
SQLを標準化するわけでも、GraphQLを置き換えるわけでも、検索APIのスキーマを決めるわけでもない。
| メソッド | ボディ | 安全 | 冪等 | 主な使いどころ |
|---|---|---|---|---|
GET | 一般に意味なし | yes | yes | URIで表せるリソース取得 |
POST | あり | no | no | 作成、送信、実行、リソース固有処理 |
PUT | あり | no | yes | 対象リソースの置き換え |
DELETE | 一般には不要 | no | yes | 対象リソースの削除 |
OPTIONS | 一般には不要 | yes | yes | 通信オプションや対応メソッドの確認 |
QUERY | あり | yes | yes | 本文で条件を送る読み取り問い合わせ |
RFC 10008では、QUERYリクエストにContent-Typeを必須としている。
メディアタイプがない、または本文と一致しない場合は4xxで失敗させる。
対応していないメディアタイプなら415 Unsupported Media Type、構文としては正しいが問い合わせ内容を処理できないなら422 Unprocessable Content、クライアントが受け取れるレスポンス形式にできないなら406 Not Acceptableが候補になる。
サーバーはAccept-Queryレスポンスヘッダーで、どの問い合わせ形式を受けられるかを示せる。
RFCの例ではapplication/jsonpathやapplication/sqlが出てくる。
Accept-Query: "application/jsonpath", application/sql;charset="UTF-8"
対象URIは問い合わせのスコープを決める。
QUERY /ordersなら注文集合に対する問い合わせ、QUERY /graphqlならGraphQLエンドポイントに対する問い合わせ、という見方になる。
URIと本文の組み合わせで問い合わせの意味が決まる。
キャッシュキーはURLだけでは作れない
QUERYで変わるのは、実装コードの見た目より中間層の挙動だ。
QUERYは冪等なので、接続失敗などで同じリクエストを再送しやすい。
POSTでもアプリケーションが冪等キーを用意すれば再試行できるが、HTTPメソッドだけでは判断できない。
QUERYなら、少なくともリクエストの意味としては再送してよいと分かる。
キャッシュはもう少し複雑になる。
RFC 10008はQUERYレスポンスをキャッシュ可能としているが、キャッシュキーには対象URIだけでなくリクエスト本文と関連メタデータを含めることを求めている。
GET /feed?q=fooならURLがほぼそのままキャッシュキーになるのに対し、QUERY /feedではボディ全体とContent-Typeまで見てキーを作ることになる。
QUERY /orders HTTP/1.1
Host: api.example.com
Content-Type: application/json
{ "status": "paid", "limit": 50 }
QUERY /orders HTTP/1.1
Host: api.example.com
Content-Type: application/json
{ "status": "cancelled", "limit": 50 }
この2つは同じURLに見えるが、キャッシュキーとしては別物でなければならない。
一番単純なのは、キャッシュ内部で「URI + 正規化したボディのハッシュ」をキーにする形だ。
method: QUERY
target: https://api.example.com/orders
content-type: application/json
accept: application/json
body-digest: sha256(canonical-json(body))
auth-scope: tenant:123:user:456
このキーはCDN、リバースプロキシ、APIゲートウェイ、アプリ内キャッシュが内部的に使うもので、人間が見るURLではない。
JSONなら空白やキー順序を揃えてからハッシュ化したいが、その正規化がサーバー側の解釈とズレると、別の問い合わせに同じレスポンスを返してしまう。
RFC 10008も、QUERYのキャッシュはGETより複雑だと明示している。
QUERYの条件はURLコピペで共有できない
GET /orders?status=paid&limit=50なら、URLそのものが問い合わせ条件を含むので、そのままブラウザ、Slack、Issue、ブックマークへ貼れる。
QUERY /ordersでは条件はボディにあり、URLだけコピーしても条件は消える。
RFC 10008は、この穴をLocationとContent-Locationで埋める道を用意している。
最初はQUERYで複雑な条件を送り、サーバーが問い合わせや結果にURIを与え、次回以降はそのURIへGETする形だ。
QUERY /reports HTTP/1.1
Host: api.example.com
Content-Type: application/json
{
"from": "2026-01-01",
"to": "2026-06-30",
"groupBy": ["region", "plan"],
"metrics": ["revenue", "churn"]
}
HTTP/1.1 200 OK
Content-Type: application/json
Location: /reports/queries/8f3a...
Content-Location: /reports/results/7b91...
Cache-Control: max-age=300
Locationは「この問い合わせ条件そのものを再実行できるURI」、Content-Locationは「今回の問い合わせ結果を取得できるURI」として使い分けられる。
| URIの種類 | 例 | 意味 |
|---|---|---|
| 問い合わせURI | /reports/queries/8f3a... | 同じ条件で再実行するためのURI |
| 結果URI | /reports/results/7b91... | その時点の結果、または一定期間保存された結果を取るURI |
問い合わせURIの裏側には「このIDはこの検索条件」という保存済み検索があり、データが更新されれば結果も変わる。
結果URIはスナップショットを指すので結果は固定になるが、保存期限や権限管理という別の設計が付いてくる。
実装としては、問い合わせ本文を正規化してハッシュ化し、/reports/queries/{hash}のようなURIを返す形が考えやすい。
RESTとGraphQLでの位置づけ
REST APIでは、QUERYはRESTの統一インターフェース(uniform interface)に追加される読み取り用メソッドとして捉えられる。
小さい取得と単純な検索は今まで通りGETでいい。
GET /orders?status=paid&limit=50 HTTP/1.1
Host: api.example.com
QUERYが効くのは、条件がURLに押し込むには大きく、でも意味としては読み取り専用のときだ。
QUERY /orders HTTP/1.1
Host: api.example.com
Content-Type: application/json
{
"filter": {
"and": [
{ "status": ["paid", "refunded"] },
{ "createdAt": { "gte": "2026-01-01", "lt": "2026-07-01" } },
{ "customer": { "segment": ["enterprise", "education"] } }
]
},
"sort": [{ "field": "createdAt", "direction": "desc" }],
"limit": 100
}
対象URIは問い合わせ対象のコレクションにする。
/orders/queryのような動詞っぽいサブリソースを作るより、QUERY /ordersのほうがHTTPメソッドに意味を寄せられる。
既存のREST APIを全部置き換える話ではなく、URLで十分に表せる取得はGETのまま、更新・作成・削除・ジョブ起動はPOST、PUT、PATCH、DELETEのままだ。
GraphQLでは事情が少し違う。
現在のGraphQL over HTTPのドラフト仕様では、サーバーはPOSTを受け付けなければならず、GETは受け付けてもよい。
また、mutationをGETで実行してはいけない。
実際のリクエストは、読み取りのqueryでもこういうPOSTになる。
POST /graphql HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/graphql-response+json
{
"query": "query ($id: ID!) { user(id: $id) { name } }",
"variables": { "id": "123" }
}
このPOSTは、GraphQLの中身としては読み取り専用のqueryかもしれない。
でもHTTPレイヤーから見るとPOSTで、中間層はGraphQL本文を理解しない限りqueryとmutationを区別できない。
QUERYをGraphQLに使うなら、こういう形が考えられる。
QUERY /graphql HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/graphql-response+json
{
"query": "query ($id: ID!) { user(id: $id) { name } }",
"variables": { "id": "123" }
}
GraphQLのqueryであることと、HTTPのQUERYであることが揃い、読み取り専用・再試行可能・キャッシュ可能という情報をHTTPメソッドとして外に出せる。
mutationは副作用を持つのでPOSTに残る。
ただし、GraphQL over HTTPの現行ドラフトがQUERYを採用しているわけではないので、実際に使うにはGraphQLサーバー、クライアント、CDN、フレームワーク側の対応を待つことになる。
| 観点 | REST API | GraphQL型API | HTTP QUERY |
|---|---|---|---|
| 主役 | リソースURIとHTTPメソッド | スキーマとクエリ言語 | HTTPメソッドの意味 |
| 典型URL | /orders, /orders/123 | /graphql | 任意の対象URI |
| 読み取り | 主にGET | POSTまたはGET | QUERY |
| 複雑な条件 | URL queryかPOST /searchになりがち | ボディにGraphQL文を置く | ボディに問い合わせ内容を置く |
| 副作用 | POST、PUT、PATCH、DELETE | mutation | QUERYでは扱わない |
想定される利用場面
一番分かりやすいのは、検索とフィルタだ。
ECサイトの商品検索、管理画面の注文検索、ログ検索、監査イベント検索、ユーザー一覧の絞り込み。
条件が数個ならGETで足りるが、条件ビルダーがあってネストしたAND/ORや配列条件を持つならQUERYが候補になる。
次に、レポートや分析API。
期間、ディメンション、メトリクス、フィルタをJSONで送るが、処理としては読み取り専用というケースだ。
今はPOST /reports/runのように書きがちだが、本当に結果取得だけならQUERY /reportsのほうが意味に合う。
GraphQLやJSONPathのような問い合わせ言語も相性がいい。
RFC 10008のAccept-Query例にもapplication/jsonpathが出てくる。
SQLライクな読み取りクエリ、ドキュメントDBの検索DSL、OData風の複雑なフィルタも、本文に問い合わせ言語を載せるという点で使い方は変わらない。
FetchやAxiosから見るとただのmethod指定
クライアントコードでは、fetchのmethodに"QUERY"を指定して本文を付ける。
const response = await fetch("https://api.example.com/orders", {
method: "QUERY",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({
filter: {
status: ["paid", "refunded"],
createdAt: {
gte: "2026-01-01",
lt: "2026-07-01",
},
},
sort: [{ field: "createdAt", direction: "desc" }],
limit: 100,
}),
});
if (!response.ok) {
throw new Error(`QUERY failed: ${response.status}`);
}
const orders = await response.json();
見た目はPOSTとほとんど同じで、違うのはHTTPメソッドとして「これは読み取り問い合わせです」と宣言しているところだけ。
ブラウザから別オリジンへ送るなら、QUERYはCORSのプリフライトリクエスト(本リクエスト前にブラウザが送るOPTIONSでの事前確認)の対象になる。
サーバー側がAccess-Control-Allow-MethodsにQUERYを入れていないと、アプリコードに届く前にブラウザが止める。
Axiosでも考え方は同じで、methodに"QUERY"を渡す。
import axios from "axios";
const { data } = await axios({
url: "https://api.example.com/orders",
method: "QUERY",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
data: {
filter: {
status: ["paid", "refunded"],
createdAt: {
gte: "2026-01-01",
lt: "2026-07-01",
},
},
sort: [{ field: "createdAt", direction: "desc" }],
limit: 100,
},
});
ライブラリのコードが書けることと、経路上で通ることは別の話だ。
フロントエンドコード、ブラウザ、プロキシ、CDN、WAF、APIゲートウェイ、バックエンドフレームワークのどこかが未知のメソッドを拒否すると失敗する。
実験するなら、まずcurl -X QUERYでオリジンまで通るか確認し、そのあとブラウザのプリフライトを確認するのがよさそうだ。
curl -i -X QUERY "https://api.example.com/orders" \
-H "Content-Type: application/json" \
-d '{"filter":{"status":["paid"]},"limit":10}'
受け側はREQUEST_METHODと本文を見る
PHPで素朴に受けるなら、$_SERVER['REQUEST_METHOD']がQUERYかどうかを確認して、php://inputから本文を読み取る流れになる。
$_POSTは「POSTメソッドなら何でも入る配列」ではなく、PHPがフォームエンコードされた本文をパースした結果だ。
application/jsonの本文は、メソッドがPOSTでもQUERYでも基本的にphp://inputから生の本文を読んでjson_decode()する。
<?php
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: https://app.example.com');
header('Access-Control-Allow-Methods: GET, POST, QUERY, OPTIONS');
header('Access-Control-Allow-Headers: content-type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'QUERY') {
header('Allow: GET, POST, QUERY, OPTIONS');
http_response_code(405);
echo json_encode(['error' => 'Method Not Allowed']);
exit;
}
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (stripos($contentType, 'application/json') !== 0) {
http_response_code(415);
echo json_encode(['error' => 'Unsupported Media Type']);
exit;
}
$rawBody = file_get_contents('php://input');
$query = json_decode($rawBody, true);
if (!is_array($query)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid JSON']);
exit;
}
// ここでDB検索などを行う。QUERYなので状態変更はしない。
$result = [
'method' => 'QUERY',
'filter' => $query['filter'] ?? null,
'items' => [],
];
echo json_encode($result, JSON_UNESCAPED_UNICODE);
Node.jsなら、フレームワークを使わない素のhttpでも同じことができる。
やることは、メソッド確認、CORSプリフライト応答、Content-Type確認、本文のJSONパース、読み取り処理、レスポンスの順だ。
import http from "node:http";
const server = http.createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "https://app.example.com");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, QUERY, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "content-type");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
if (req.method !== "QUERY") {
res.writeHead(405, {
"Content-Type": "application/json; charset=utf-8",
"Allow": "GET, POST, QUERY, OPTIONS",
});
res.end(JSON.stringify({ error: "Method Not Allowed" }));
return;
}
const contentType = req.headers["content-type"] ?? "";
if (!contentType.startsWith("application/json")) {
res.writeHead(415, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "Unsupported Media Type" }));
return;
}
let rawBody = "";
for await (const chunk of req) {
rawBody += chunk;
}
let query;
try {
query = JSON.parse(rawBody);
} catch {
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: "Invalid JSON" }));
return;
}
// ここでDB検索などを行う。QUERYなので状態変更はしない。
const result = {
method: "QUERY",
filter: query.filter ?? null,
items: [],
};
res.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "max-age=60",
});
res.end(JSON.stringify(result));
});
server.listen(3000);
Expressなどのフレームワークでも、ルーターが任意メソッドを拾えるなら同じ発想になる。
ただし、ミドルウェアやルーターが未知のHTTPメソッドを落とす設定になっていることがあるので、app.all()で拾えるか、独自メソッドを明示登録できるかを先に確認する。
使うときの注意
QUERYは標準化されたが、すぐに全環境で自然に通るとは限らない。
新しいHTTPメソッドは、ロードバランサ、CDN、WAF、APIゲートウェイ、フレームワーク、ルーター、CORS設定、監視ツールのどこかで許可リストに弾かれることがある。
ブラウザからクロスオリジンで使う場合、QUERYはCORSのプリフライト対象になる。
OPTIONSへの応答でAccess-Control-Allow-Methods: QUERYを返さないと通らない。
OPTIONS /feed HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: QUERY
Access-Control-Request-Headers: content-type
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, QUERY
Access-Control-Allow-Headers: content-type
キャッシュを効かせるなら、ボディを含むキャッシュキーの扱いを先に確認する。
CDNがQUERYを理解していなければ単にオリジンへ素通しするだけだし、本文を見ずにURLだけでキャッシュする実装が挟まると、異なる問い合わせに同じレスポンスを返してしまう。
サーバー側のContent-Typeチェックについて、RFC 10008は、Content-Typeがない、または本文と矛盾している場合に失敗させることを求めている。
application/jsonとして受けるならJSONとしてパースし、独自DSLならそのメディアタイプを決めてAccept-Queryで宣言する。
ここを曖昧にすると、POST /search時代の暗黙ルールを新しいメソッドに移しただけになる。
URIはアクセスログ、アクセス解析、ブラウザ履歴、Referer、ブックマークに残りやすい。
QUERYなら、URLに載せたくない検索条件を本文へ移せる。
ただし、APIゲートウェイやアプリケーションログがリクエストボディを保存していれば、本文に移しても結局は記録に残る。
QUERYを使うべきでない場面もある。
状態を変える処理、ジョブを起動する処理、課金や送信を発生させる処理、監査上「1回だけ実行」が重要な処理はQUERYではない。
読み取りでも、URLで十分に表せて共有したいものはGETのままでいい。