HTTP に新メソッド「QUERY」がやってきた ── RFC 10008 を Node.js で動かしてみる

2026.07.05

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 のように安全・冪等・キャッシュ可能として扱う。それだけです。

GETPOSTQUERY
リクエストボディ
安全(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 以外405Allow を添える

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"}}\'');
});