
GET でも POST でもない、3つ目の「読み取り用」メソッド。PATCH 以来ひさびさの汎用 HTTP メソッド追加を、動くコードで理解する。
2026年6月、IETF から RFC 10008: The HTTP QUERY Method が Proposed Standard として公開されました。著者は Julian Reschke(greenbytes)、James M. Snell(Cloudflare)、Mike Bishop(Akamai)の3名。汎用的に使える新しい HTTP メソッドが標準化されるのは、2010年の PATCH(RFC 5789)以来です。
「新メソッドが増えた」というだけでも珍しい出来事ですが、QUERY が面白いのはこれまでどのメソッドも同時に満たせなかった4つの性質を、1つのメソッドで揃えた点にあります。この記事では、なぜ QUERY が必要とされたのか、そして実際に Node.js で最小のサーバを書いて動かすところまでを見ていきます。
なぜ GET でも POST でもダメだったのか
検索やフィルタのような「データを読み取るだけ」のリクエストを送るとき、私たちには実質2つの選択肢しかありませんでした。どちらも一長一短です。
GET はセマンティクス的には正解。でもURLに全部詰め込む必要がある。 GET は安全(サーバの状態を変えない)・冪等(何度投げても同じ)・キャッシュ可能という、読み取りに欲しい性質をすべて備えています。問題はクエリを URL に載せなければならないこと。RFC 9110 は URL 長として最低 8000 オクテットのサポートを推奨していますが、これはあくまで下限であり、リクエストは複数の無関係な中継(プロキシ、ロードバランサ、CDN)を通るため、実際に通る上限は事前にはわかりません。加えて、ネストした複雑なフィルタや JSON 構造を URL エンコードするのは非効率で読みづらく、URL はログやブラウザ履歴、ブックマークに残りやすいというプライバシー上の懸念もあります。
POST はボディを送れる。でも「安全」だと誰も保証してくれない。 そこで多くの実装は POST に逃げます。ボディに好きなだけクエリを書けるからです。ところが POST は本来リソースを作ったり副作用を起こしたりするためのメソッドで、プロトコル上「これは読み取り専用ですよ」というシグナルがどこにもありません。だからキャッシュは効かず、自動リトライも安全にできません。GraphQL の read-only クエリがほぼ全て POST で送られているのが、この問題の一番目立つ例でしょう。
「じゃあ GET にボディを付ければ?」という発想は昔からありますが、これは地雷です。HTTP の各 RFC は GET にボディを付けることを明示的に禁止こそしていませんが、推奨もしていません。結果として、ボディ付き GET を拒否する実装、黙って捨てる実装、解釈する実装が入り混じっており、環境によって動いたり動かなかったりします。GET を再定義すると既存インフラが壊れるため、「GET にボディを認める RFC」ではなく「新しいメソッド」という形が選ばれました。
QUERY は「GETのセマンティクス + POSTのボディ」
QUERY のアイデアはシンプルです。POST のようにボディでクエリを送り、GET のように安全・冪等・キャッシュ可能として扱う。それだけです。
| GET | POST | QUERY | |
|---|---|---|---|
| リクエストボディ | ✕ | ○ | ○ |
| 安全(safe) | ○ | ✕ | ○ |
| 冪等(idempotent) | ○ | ✕ | ○ |
| キャッシュ可能 | ○ | 条件付き | ○ |
重要なのは、この「安全・冪等」は推論ではなく宣言だという点です。QUERY は IANA の HTTP Method Registry に safe・idempotent として登録されているため、あなたの API を何も知らない汎用のクライアント・プロキシ・キャッシュが、そのままそう扱ってよいことになります。これまで「APIドキュメントに書いてあるから(読んでくれていれば)安全」だった保証が、「プロトコルに刻まれていて機械的に強制できる」保証に格上げされた、というのが本質です。
最小のやり取りはこんな形になります。
QUERY /contacts HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/json
{"select":["surname","email"],"match":{"surname":"Sato"}}
HTTP/1.1 200 OK
Content-Type: application/json
Content-Location: /contacts/results/eyJzZWxlY3Qi
Accept-Query: application/json
{"count":1,"results":[{"surname":"Sato","email":"..."}]}
Node.js で最小の QUERY サーバを書く
理屈より動くコードです。生の node:http で書くと、メソッドの振り分けとステータスコードの出し分けがそのまま見えて分かりやすいので、あえてフレームワークなしでいきます。
ポイントは、RFC 10008 §2.1 が定めるステータスコードの出し分けです。ここが QUERY 実装の肝であり、素朴に書くと間違えやすいところでもあります。
const http = require("node:http");
const CONTACTS = [
{ surname: "Tanaka", givenname: "Hana", email: "hana@example.com", age: 32 },
{ surname: "Suzuki", givenname: "Ken", email: "ken@example.com", age: 41 },
{ surname: "Sato", givenname: "Mei", email: "mei@example.com", age: 27 },
];
const QUERYABLE = new Set(["surname", "givenname", "email", "age"]);
const ACCEPT_QUERY = "application/json"; // このリソースが受け付けるクエリ形式
const server = http.createServer(async (req, res) => {
// QUERY 以外は 405。Allow と Accept-Query を添えるのが親切
if (req.method !== "QUERY") {
return send(res, 405, { error: "method not allowed" },
{ Allow: "OPTIONS, QUERY", "Accept-Query": ACCEPT_QUERY });
}
const contentType = (req.headers["content-type"] || "").split(";")[0].trim();
// (1) Content-Type 欠落 → 中身を推測してはいけない → 400
if (!contentType) return send(res, 400, { error: "Content-Type required" });
// (2) 非対応のメディアタイプ → 415
if (contentType !== "application/json")
return send(res, 415, { error: `unsupported media type: ${contentType}` });
// (3) Accept を満たせない → 406
if (!acceptsJSON(req.headers["accept"]))
return send(res, 406, { error: "cannot satisfy Accept" });
const raw = await readBody(req);
// (4) Content-Type は合っているが本文が壊れている → 400(415 ではない)
let q;
try { q = JSON.parse(raw.toString("utf8")); }
catch { return send(res, 400, { error: "malformed JSON" }); }
// (5) 構文は正しいがクエリとして処理できない(存在しない列など)→ 422
const reason = validate(q);
if (reason) return send(res, 422, { error: `unprocessable: ${reason}` });
// 成功。等価リソースのURIを2種類返す(後述)
const id = Buffer.from(raw).toString("base64url").slice(0, 12);
return send(res, 200, { count: run(q).length, results: run(q) }, {
"Content-Location": `/contacts/results/${id}`,
"Location": `/contacts/stored-queries/${id}`,
"Accept-Query": ACCEPT_QUERY,
"Cache-Control": "max-age=60",
"Vary": "Accept",
});
});
server.listen(3000);
ステータスコードの分岐を表にまとめると、記事としても実装チェックリストとしても使えます。
| 状況 | ステータス | 理由 |
|---|---|---|
| クエリ成功 | 200 | 結果を返す |
| Content-Type 欠落 | 400 | 中身の推測(sniffing)は禁止 |
| 非対応のメディアタイプ | 415 | クエリ形式を解釈できない |
| Content-Type は合うが本文が壊れている | 400 | メディアタイプ自体は対応しているので 415 ではない |
| 構文は正しいが処理不能(存在しない列など) | 422 | クエリの意味が通らない |
| Accept を満たせない | 406 | 要求された表現を返せない |
| GET 等 QUERY 以外 | 405 | Allow を添える |
curl から叩くときは、メソッド名を必ず大文字 QUERY で指定します。
curl -X QUERY http://localhost:3000/contacts \
-H 'Content-Type: application/json' \
-d '{"select":["surname","email"],"match":{"surname":"Sato"}}'
(この記事のフルソースは末尾にリンクした query-server.js にまとめてあります。node query-server.js で起動できます)
ブラウザから使うときの「動くけど罠」
「じゃあ fetch でそのまま使えるの?」── 答えは一応イエス、ただし注意点が3つです。
const res = await fetch("https://api.example.com/contacts", {
method: "QUERY", // ← 必ず大文字
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify({ select: ["surname", "email"], match: { surname: "Sato" } }),
});
① メソッド名は大文字で書く。 fetch の “normalize a method” が大文字化してくれるのは DELETE / GET / HEAD / OPTIONS / POST / PUT だけです。QUERY はこのリストに含まれないため、method: "query" と小文字で書くと小文字のまま送信され、IANA 登録名 QUERY と一致しなくなります。地味ですが確実にハマります。
② クロスオリジンだと必ずプリフライトが飛ぶ。 QUERY は CORS のセーフリストに載っていないため、オリジンをまたぐ QUERY リクエストの前には必ず OPTIONS のプリフライトが発生します。これはバグではなく新メソッドの当然の帰結ですが、ホットパスでは1往復ぶんのレイテンシが増えるので、ネットワークウォーターフォールに余計なホップが出ても驚かないように。
③「キャッシュ可能」だが、今はまだ誰もキャッシュしない。 仕様上 QUERY のレスポンスはキャッシュ可能です。ただし現時点では Chrome も Firefox も、同一の QUERY を繰り返してもキャッシュしません。「仕様上は可能」と「今この瞬間に効く」は別物なので、記事でもプロダクトでも混同しないのが大事です。ちなみにキャッシュキーには URL だけでなくリクエストボディも含める必要があり、GET より扱いが複雑になります。
Location と Content-Location を混同しない
成功レスポンスで返している2つのヘッダは、見た目が似ていますが役割がまったく違います。ここは実装が一番雑になりやすいポイントです。
Content-Location… 「今返したこの結果のスナップショットはここにあります」。あとでGETすれば同じ結果を取り直せる、そのクエリ結果のURI。Location… 「同じクエリを、ボディを再送せずに再実行したいならここ」。クエリそのものを指すURI。
前者は「取得した答え」、後者は「もう一度同じ質問をする窓口」です。この2つを「どっちも今のリクエストに関連するURL」と雑にまとめてしまうと、元データが更新されたときにキャッシュのバグとして表面化します。混同しないこと。
なお、高コストなクエリをサーバ側で先に生成しておきたい場合は、結果を返さずに 303 See Other で等価リソースへリダイレクトする、という選択肢もあります。CDN と相性が良いパターンです。
今すぐ全面移行すべきか? ── まだ早い
ここまで読むと「検索系エンドポイントを全部 QUERY に置き換えたい」と思うかもしれませんが、待ってください。RFC 10008 は「確定した標準」であって「出荷済みの機能」ではありません。
実際に使えるようになるには、HTTP クライアント(curl、fetch、axios など)、サーバフレームワーク(Express、FastAPI、Spring など)、そして CDN のキャッシュエンジンまで、スタック全体の対応が必要です。Node.js の undici、Jetty、Tomcat などで実装や議論が始まっている段階で、ブラウザの fetch への正式統合には WHATWG 側の作業が必要となり、まだ時間がかかります。「あらゆる場所で完全にサポートされるまでには何年もかかるかもしれない」というのが現実的な見立てです。
一方で、著者に Cloudflare と Akamai のエンジニアが名を連ねていることは大きなシグナルで、CDN レベルの対応がフレームワーク統合より先に来る可能性があります。
今できる現実的なアクションは、こんなところです。
- RFC 10008 に一度目を通しておく
- 自分の API の中で「読み取り専用なのに POST を使っている」エンドポイントを洗い出しておく(QUERY の第一候補)
- curl や fetch は今でもカスタムメソッドとして QUERY を送れるので、対応サーバに対して実験的に試しておく
- 各フレームワーク/CDN の対応アナウンスを追う
なお、「GraphQL を QUERY に載せる」話は魅力的ですが、RFC 10008 が定めるのはメソッドであってペイロード形式ではありません。GraphQL-over-QUERY の是非は別途の議論であり、この RFC の範囲外である点は押さえておきましょう。
まとめ
QUERY は派手な機能ではありませんが、HTTP の初期から空いていた「安全・冪等なのにボディを持てるメソッド」という穴を、きれいに埋めるものです。
- GET の正しさ(安全・冪等・キャッシュ可能)を、POST の柔軟さ(ボディ)と両立させた
- 「安全です」がドキュメントではなくプロトコルの保証になった
- 実装の肝はステータスコードの出し分け(400 / 415 / 422 / 406 の使い分け)と、Location / Content-Location の区別
- ブラウザからは「一応動く」が、大文字メソッド名・CORS プリフライト・キャッシュ未実装の3点に注意
- 標準化は完了したが、エコシステムの対応はこれから。全面移行は時期尚早
動くコードを手元で叩きながら仕様を読むと、驚くほどスッと入ってきます。まずは query-server.js を起動して、上の curl を投げるところから始めてみてください。
参考: RFC 10008 — The HTTP QUERY Method (IETF, June 2026)
// RFC 10008: The HTTP QUERY Method — Node.js 実装サンプル
//
// 生の node:http でメソッドディスパッチとステータスコードの出し分けを実演する。
// 実行: node query-server.js (Node 18+ を想定)
//
// QUERY は「GET のセマンティクス(安全・冪等・キャッシュ可能) + POST のボディ」を
// 併せ持つメソッド。ここでは application/json をクエリ形式として受け付ける。
const http = require("node:http");
// --- ダミーのデータセット(本来はDB等) -----------------------------------
const CONTACTS = [
{ surname: "Tanaka", givenname: "Hana", email: "hana@example.com", age: 32 },
{ surname: "Suzuki", givenname: "Ken", email: "ken@example.com", age: 41 },
{ surname: "Sato", givenname: "Mei", email: "mei@example.com", age: 27 },
{ surname: "Watanabe", givenname: "Riku", email: "riku@example.com", age: 35 },
];
const QUERYABLE_FIELDS = new Set(["surname", "givenname", "email", "age"]);
// このリソースが受け付けるクエリ形式(Accept-Query で広告する)。
// Accept-Query は RFC 9651 の Structured Field(メディアレンジのリスト)。
const ACCEPT_QUERY = "application/json";
// --- ヘルパ ----------------------------------------------------------------
function sendJSON(res, status, obj, extraHeaders = {}) {
const body = JSON.stringify(obj);
res.writeHead(status, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
...extraHeaders,
});
res.end(body);
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}
// クエリ本体の意味的な妥当性を検証する。
// 「構文は正しいが処理できない」場合は 422 を返すために null 以外の理由を返す。
function validateQuery(q) {
if (typeof q !== "object" || q === null || Array.isArray(q)) {
return "query must be a JSON object";
}
if (q.select !== undefined) {
if (!Array.isArray(q.select)) return "`select` must be an array";
for (const f of q.select) {
if (!QUERYABLE_FIELDS.has(f)) return `unknown field in select: ${f}`;
}
}
if (q.match !== undefined) {
if (typeof q.match !== "object" || q.match === null) {
return "`match` must be an object";
}
for (const f of Object.keys(q.match)) {
if (!QUERYABLE_FIELDS.has(f)) return `unknown field in match: ${f}`;
}
}
if (q.limit !== undefined && (!Number.isInteger(q.limit) || q.limit < 0)) {
return "`limit` must be a non-negative integer";
}
return null; // OK
}
function runQuery(q) {
let rows = CONTACTS;
if (q.match) {
rows = rows.filter((row) =>
Object.entries(q.match).every(([k, v]) => row[k] === v)
);
}
if (Array.isArray(q.select) && q.select.length > 0) {
rows = rows.map((row) => {
const picked = {};
for (const f of q.select) picked[f] = row[f];
return picked;
});
}
if (typeof q.limit === "number") rows = rows.slice(0, q.limit);
return rows;
}
// Accept ヘッダが application/json を許容するかの雑な判定(記事用に簡略化)。
function acceptsJSON(accept) {
if (!accept) return true; // 未指定は何でも可
return accept
.split(",")
.map((s) => s.split(";")[0].trim())
.some((m) => m === "application/json" || m === "application/*" || m === "*/*");
}
// --- サーバ本体 ------------------------------------------------------------
const server = http.createServer(async (req, res) => {
if (req.url !== "/contacts") {
return sendJSON(res, 404, { error: "not found" });
}
// OPTIONS: サポート状況の発見用。Allow と Accept-Query を返す。
// (クライアントは本リクエスト前に QUERY 対応可否を確認できる)
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Allow": "OPTIONS, QUERY",
"Accept-Query": ACCEPT_QUERY,
});
return res.end();
}
// QUERY 以外は 405。Allow と Accept-Query を添えるのが親切。
if (req.method !== "QUERY") {
return sendJSON(
res,
405,
{ error: `method ${req.method} not allowed on /contacts` },
{ "Allow": "OPTIONS, QUERY", "Accept-Query": ACCEPT_QUERY }
);
}
// --- ここから QUERY の処理。RFC 10008 §2.1 のステータス規則を実演 -------
// (1) Content-Type 必須。欠落していたら中身を推測してはいけない → 400。
const contentType = (req.headers["content-type"] || "").split(";")[0].trim();
if (!contentType) {
return sendJSON(res, 400, {
error: "Content-Type is required for QUERY",
}, { "Accept-Query": ACCEPT_QUERY });
}
// (2) 対応していないメディアタイプ → 415。
if (contentType !== "application/json") {
return sendJSON(res, 415, {
error: `unsupported query media type: ${contentType}`,
}, { "Accept-Query": ACCEPT_QUERY });
}
// (3) Accept を満たせない → 406。
if (!acceptsJSON(req.headers["accept"])) {
return sendJSON(res, 406, {
error: "cannot produce a response matching Accept",
});
}
const raw = await readBody(req);
// (4) Content-Type は application/json だが本文が壊れている → 400。
// (415 ではない。メディアタイプ自体は対応しているため)
let parsed;
try {
parsed = JSON.parse(raw.toString("utf8"));
} catch {
return sendJSON(res, 400, { error: "malformed JSON body" });
}
// (5) 構文は正しいがクエリとして処理できない(存在しないフィールド等)→ 422。
const reason = validateQuery(parsed);
if (reason) {
return sendJSON(res, 422, { error: `unprocessable query: ${reason}` });
}
// --- 成功。結果を返す ----------------------------------------------------
const results = runQuery(parsed);
// 「等価リソース(equivalent resource)」の2つのURIの返し分けが肝。
// Content-Location: 今返したこの結果スナップショットのURI(後でGETで取り直せる)
// Location: 同じクエリをボディ無しで再実行できるURI
// この2つは意味が違うので混同しないこと(混同するとキャッシュバグの温床)。
const queryId = Buffer.from(raw).toString("base64url").slice(0, 12);
return sendJSON(res, 200, { count: results.length, results }, {
"Content-Location": `/contacts/results/${queryId}`,
"Location": `/contacts/stored-queries/${queryId}`,
"Accept-Query": ACCEPT_QUERY,
// QUERY は安全・冪等なのでキャッシュ可能。ただしキャッシュキーには
// 本文を含める必要がある(GETと違いURIだけでは一意にならない)。
"Cache-Control": "max-age=60",
"Vary": "Accept",
});
});
server.listen(3000, () => {
console.log("HTTP QUERY sample server listening on http://localhost:3000");
console.log("Try: curl -X QUERY http://localhost:3000/contacts \\");
console.log(" -H 'Content-Type: application/json' \\");
console.log(' -d \'{"select":["surname","email"],"match":{"surname":"Sato"}}\'');
});