HTTP の QUERY メソッドと、本当に対応しているところ

シェア

6 月、IETF は QUERY という新しい HTTP メソッドを定義する RFC 10008 を公開しました。前回標準化されたメソッドは 2010 年の PATCH です。QUERY はボディを持つ GET のように動きます。安全で、冪等で、キャッシュ可能で、クエリは URL ではなく リクエストボディに入ります。

誰もがすでに対応していそうに聞こえるので、試してみました。私たちのものを含む 5 つの公開テスト API に QUERY リクエストを 送ったところ、返ってきた応答は 5 つともばらばらで、クエリを実行したのは 1 つだけでした。Node、ASP.NET Core、Python も 試しましたが、3 つとも問題なく扱えました。アプリケーションのコードでは、このメソッドはもう使えます。途中にあるプロキシや サーバーは別の話です。

QUERY が解決する問題

十数個のフィルター、ネストした価格帯、並び順を持つ商品検索を考えてみてください。今の選択肢は 2 つで、どちらにも欠点があります。

  • すべてをクエリ文字列に入れた GET。ネストしたフィルターを何とかして URL にエンコードしなければなりません。 長い URL はサーバーやプロキシの長さ制限に引っかかり、クエリ全体がアクセスログ、ブラウザの履歴、Referer ヘッダーに残ります。GET にボディを付けても解決しません。RFC 9110 は GET リクエストの内容に定義された意味はないとしており、多くのプロキシやクライアントはそれを捨てます。
  • POST /search。ほとんどの API が最終的にこうしています。動きはしますが、POST はすべてのクライアントと プロキシに「このリクエストは何かを変えるかもしれない」と伝えます。そのため、接続が切れた後に自動でリトライされることはなく、 結果をキャッシュするものもありません。

QUERY なら、クライアント、プロキシ、キャッシュが GET と同じように扱えるリクエストボディが手に入ります。

GETPOSTQUERY
リクエストボディに定義された意味があるいいえはいはい
安全(何も変えない)はいいいえはい
冪等(リトライしてよい)はいいいえはい
レスポンスをキャッシュできるはいまれはい(ボディをキーに含める)
クエリが URL やログに残るはいいいえいいえ

QUERY リクエストの見た目

POST のように見えますが、メソッド名が途中のすべてに読み取り専用であることを伝えます。

リクエスト
QUERY /products/search HTTP/1.1
Host: shop.example
Content-Type: application/json
Accept: application/json

{"category":"electronics","maxPrice":500,"inStock":true,"sort":"price"}
レスポンス
HTTP/1.1 200 OK
Content-Type: application/json
Location: /products/search/q/7f3a9c
Cache-Control: max-age=60

[{"id":4,"name":"USB-C hub","price":39},{"id":11,"name":"Mechanical keyboard","price":89}]

Location ヘッダーは任意ですが、RFC の中でも特に便利な部分です。同じクエリを普通の GET で実行できる URL を示すので、クライアントは一度ボディを送れば、あとは普通のリンクを使えます。Content-Location の意味は少し違い、この特定の結果を返す URL です。サーバーは結果を一切含めずに 303 See Other で応答することもでき、その場合クライアントは Location の URL から GET で結果を取りに行きます。

RFC がサーバーに求めること

ボディこそがクエリなので、ほとんどはボディを厳密に扱うことについてです。

  • Content-Type は必須です。ない場合、サーバーは 4xx(たいていは 400)でリクエストを拒否します。ボディから型を推測して はいけません。
  • そのリソースがクエリとして受け付けない形式には 415、宣言した型と合わないボディには 400 を返し、形式は正しいがサーバーが実行できないクエリには 422 を返します。
  • 新しいレスポンスヘッダー Accept-Query は、リソースが受け付けるクエリ形式を並べます。QUERY に対応していること自体もクライアントに伝えます。
レスポンス
HTTP/1.1 415 Unsupported Media Type
Accept-Query: application/json, "application/jsonpath"
  • キャッシュは QUERY のレスポンスを保存できますが、キャッシュキーにはリクエストボディを含める必要があります。同じ URL への フィルターの違う 2 つのクエリは、別々のキャッシュエントリです。
  • 301 や 302 のリダイレクトの後、クライアントは新しい URL に QUERY を送り直します。クライアントはこうしたリダイレクトの後に POST を GET に変えることがよくありますが、RFC は QUERY についてそれを禁じています。

5 つの公開テスト API に QUERY を送ってみた

テスト API は送ったものを返すように作られているので、どこよりも寛容なサーバーのはずです。スクリプトはこちらです。

probe-query.mjs
// Send one QUERY request to each URL and report what came back.
const targets = [
  "https://httpbin.org/anything",
  "https://httpbingo.org/anything",
  "https://postman-echo.com/query",
  "https://jsonplaceholder.typicode.com/posts",
  "https://api.testingapis.com/api/methods/any",
];

for (const url of targets) {
  const res = await fetch(url, {
    method: "QUERY", // capitals: fetch only uppercases the six standard methods
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ category: "electronics", maxPrice: 500 }),
  });
  const type = (res.headers.get("content-type") ?? "-").split(";")[0];
  const allow = res.headers.get("allow") ?? "-";
  console.log(new URL(url).host.padEnd(29), res.status, type.padEnd(17), "allow:", allow);
}
出力(Node 24、2026 年 10 月 10 日)
httpbin.org                   405 text/html         allow: HEAD, POST, TRACE, PUT, GET, OPTIONS, DELETE, PATCH
httpbingo.org                 200 application/json  allow: -
postman-echo.com              400 text/plain        allow: -
jsonplaceholder.typicode.com  404 application/json  allow: -
api.testingapis.com           400 text/plain        allow: -

5 つのサーバーから、5 つの違う答えが返りました。

APIレスポンスクライアントに伝わること
httpbingo.orgメソッドとボディを返す 200動きます。httpbingo は httpbin を Go で書き直したもので、Go の net/http はどんなメソッドも通します。
httpbin.orgAllow ヘッダー付きの 405正しい断り方です。そのメソッドには対応しておらず、対応しているメソッドの一覧はこれ、と伝えます。
jsonplaceholder.typicode.com404誤解を招きます。URL は存在するのにルーターが行き先を見つけられず、クライアントはパスを間違えたと思うでしょう。
postman-echo.com400、11 バイトの text/plainリクエストが不正だと言いますが、不正ではありませんでした。
api.testingapis.com400、11 バイトの text/plain同じです。そしてこれは私たちの API です。

私たちの API が失敗している様子です。

Terminal
$ curl -i -X QUERY https://api.testingapis.com/api/methods/any \
    -H "content-type: application/json" -d '{"q":"x"}'
HTTP/1.1 400 Bad Request
content-length: 11
content-type: text/plain
connection: close

Bad Request

このレスポンスはアプリケーションまで届いていません。私たちの ASP.NET Core アプリが返すレスポンスにはすべて server: Kestrel と x-powered-by: TestingAPIs のヘッダーが付きますが、これにはどちらもありません。API は Azure Container Apps で動いていて、その前にあるイングレスの プロキシが、メソッドを渡す前に拒否したのです。Postman Echo も同じ 11 バイトのボディと x-envoy-upstream-service-time ヘッダーを返すので、Envoy の背後で同じことが起きているようです。FOO のような架空のメソッドでも同じ 400 が返るので、プロキシは QUERY を不正なリクエストではなく未知のメソッドとして扱っています。

教訓は、問題がどこにあるかです。フレームワークは QUERY を問題なく扱えても、その前にあるロードバランサー、イングレス、WAF がリクエストを断ることがあり、返ってくるエラーからはそれが読み取れません。だから QUERY は localhost だけでなく、本物の クライアントが通るすべての経路を通してテストしてください。

フレームワークはすでに対応している

アプリケーションのコードはずっと状況が良好です。この記事を書いているマシンで 3 つのサーバーを試したところ、どれも追加の 設定なしで QUERY を扱えました。

Node.js 24。組み込みの HTTP パーサーがこのメソッドを知っており、fetch も送れます。

server.mjs
import http from "node:http";

console.log(http.METHODS.includes("QUERY")); // true on Node 24

http
  .createServer((req, res) => {
    if (req.method !== "QUERY") {
      res.writeHead(405, { allow: "QUERY" }).end();
      return;
    }
    let body = "";
    req.on("data", (chunk) => (body += chunk));
    req.on("end", () => {
      const filters = JSON.parse(body);
      res.writeHead(200, { "content-type": "application/json" });
      res.end(JSON.stringify({ method: req.method, filters }));
    });
  })
  .listen(3000);

ASP.NET Core 10。.NET 10 にはサーバー側の HttpMethods.Query と、HttpClient 用の HttpMethod.Query があります。

Program.cs
using System.Net.Http.Json;

var app = WebApplication.Create(args);

app.MapMethods("/products/search", [HttpMethods.Query], async (HttpRequest req) =>
{
    var filters = await req.ReadFromJsonAsync<ProductFilter>();
    return Results.Ok(new { method = req.Method, filters });
});

app.Urls.Add("http://localhost:5000");
await app.StartAsync();

// Call it with HttpClient, then try a GET on the same route.
using var http = new HttpClient();
var msg = new HttpRequestMessage(HttpMethod.Query, "http://localhost:5000/products/search")
{
    Content = JsonContent.Create(new { category = "electronics", maxPrice = 500 })
};
var res = await http.SendAsync(msg);
Console.WriteLine($"QUERY: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");

var get = await http.GetAsync("http://localhost:5000/products/search");
Console.WriteLine($"GET:   {(int)get.StatusCode} Allow: {string.Join(", ", get.Content.Headers.Allow)}");

await app.StopAsync();

record ProductFilter(string Category, decimal MaxPrice);
出力(.NET 10)
QUERY: 200 {"method":"QUERY","filters":{"category":"electronics","maxPrice":500}}
GET:   405 Allow: QUERY

2 行目に注目してください。QUERY しか扱わないルートへの GET には、何も書かなくても Allow: QUERY 付きの 405 が返ります。httpbin.org が返したのと同じ、丁寧な断り方です。

Python。http.server は各リクエストを do_<METHOD> ハンドラーに渡すので、QUERY の追加はメソッド 1 つで済みます。クライアント側では urllib.request.Request が method="QUERY" を受け付けます。

server.py
from http.server import BaseHTTPRequestHandler, HTTPServer
import json

class Handler(BaseHTTPRequestHandler):
    def do_QUERY(self):
        length = int(self.headers.get("content-length", 0))
        filters = json.loads(self.rfile.read(length))
        body = json.dumps({"method": self.command, "filters": filters}).encode()
        self.send_response(200)
        self.send_header("content-type", "application/json")
        self.end_headers()
        self.wfile.write(body)

HTTPServer(("127.0.0.1", 8000), Handler).serve_forever()

大文字で書く

HTTP のメソッド名は大文字と小文字を区別します。fetch の標準が大文字にしてくれるのは DELETE、GET、HEAD、OPTIONS、POST、PUT の 6 つだけで、それ以外は書いたとおりに送られます。3 通りの 書き方に対して Node のサーバーが返したのはこれです。

出力(Node 24)
"query" -> 400
"QUERY" -> 200 QUERY
"post" -> 200 POST

method: "query" は小文字の query のまま送られ、サーバーは 400 を返します。PATCH にも何年も前から 同じ落とし穴があり、Node は "patch" と書くと警告を出しますが、"query" には警告しません。QUERY リクエストが理由もわからず失敗するなら、大文字かどうかを確認してください。

ブラウザは先にプリフライトを送る

QUERY は、ブラウザが事前の確認なしにクロスオリジンで送れる短いメソッドのリストに入っていません。そのためクロスオリジンの QUERY の前に、ブラウザは OPTIONS のプリフライトを送り、サーバーは Access-Control-Allow-Methods に QUERY を含める必要があります。私たちのクエリを実行した唯一の API である httpbingo が、ブラウザにどう答えるかを見てみましょう。

Terminal
$ curl -i -X OPTIONS https://httpbingo.org/anything \
    -H "Origin: https://example.com" \
    -H "Access-Control-Request-Method: QUERY" \
    -H "Access-Control-Request-Headers: content-type"
HTTP/1.1 200 OK
access-control-allow-origin: https://example.com
access-control-allow-methods: GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS
access-control-allow-headers: content-type

そのリストに QUERY はないので、Node からは動いた同じリクエストが、ブラウザでは CORS エラーで失敗します。CORS の設定で許可するメソッドを名前で並べているなら、そこに QUERY も加えてください。

OpenAPI 3.2 で記述できる

2025 年 9 月にリリースされた OpenAPI 3.2 は、パスアイテムに get や post と並ぶ query 操作を追加しました。

openapi.yaml
openapi: 3.2.0
info:
  title: Shop
  version: 1.0.0
paths:
  /products/search:
    query:
      summary: Search products with filters in the body
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProductFilter"
      responses:
        "200":
          description: Matching products

ドキュメント生成ツール、クライアント生成ツール、API ゲートウェイがこのフィールドをもう読むかどうかは別の問題です。ネットワーク と同じく、頼る前に確かめてください。

もう QUERY を使うべきか

すべての経路を自分で管理できる、自社サービス間の通信なら、使うべきです。経路全体を一度テストすれば、POST /search では得られないリトライとキャッシュが手に入ります。

公開 API なら、何も取り除かずに追加します。

  • POST /search は動かしたまま、同じリソースに QUERY を追加する。QUERY を送れないクライアントやネットワークにも 入口が残ります。
  • Accept-Query を送り、クライアントが試す前に QUERY 対応を知れるようにする。
  • Location ヘッダーを返す。クライアントは一度クエリを実行すれば、どんなプロキシも通る GET で繰り返せます。
  • QUERY に対応しないルートでは、Allow ヘッダー付きの 405 を返す。クライアントのリクエストに問題があるように 見える 400 や 404 は返さない。

メソッドを拒否するサーバーをクライアントがどう扱うかを確かめたいなら、HTTP メソッドのレッスンで 405 と Allow を扱い、エラーレスポンスのガイドで私たちの API から好きなときに 4xx や 5xx を受け取る方法を紹介しています。

PATCH はクライアント、プロキシ、フレームワークをまたいで確実に動くまでに何年もかかりました。QUERY もおそらく時間がかかる でしょう。今回違うのは、アプリケーションのコードが初日から準備できていることです。まだ失敗しているのはプロキシのほうです。