HTTPに検索のための新しいメソッドが登場
HTTP QUERYは、リクエストボディで検索する安全かつ冪等なメソッドです。キャッシュや再試行の仕組み、対応クライアント、遮断される環境を解説します。
HTTP QUERYメソッドは、2026年6月にRFC 10008で定義されました。POSTと同様にクエリをリクエストボディに格納しつつ、GETと同様に安全(safe)かつ冪等(idempotent)です。そのため、レスポンスをキャッシュでき、失敗したリクエストを自動的にリトライできます。
多くのAPIチームが経験しているとおり、検索エンドポイントは最初はすっきりしたGETでも、やがてネストしたフィルター、日付範囲、ソートキーをURLエンコードして詰め込んだURLになりがちです。そこで誰かがPOSTに移行すると、今度はキャッシュとリトライが機能しなくなります。
本記事では、QUERYが解決する問題、ワイヤ上でのQUERYのやり取り、そしてスタックのどの部分がQUERYを受け付け、どの部分がまだ拒否するのかを解説します。
重要なポイント
- QUERYは、クエリをリクエストボディで運ぶ、安全かつ冪等なHTTPメソッドです。RFC 10008(2026年6月)で標準化されています。
- サーバーは、Content-Typeが欠落しているか、ボディと一致しないQUERYリクエストを拒否しなければなりません。QUERYレスポンスを保存するキャッシュは、リクエストボディをキャッシュキーに含めます。
- スクリプトからは
fetch()でQUERYを送信できますが、method="query"を指定したHTMLフォームはGETにフォールバックし、ボディは破棄されます。 - QUERYはCORSセーフリストに含まれるメソッドではないため、ブラウザからのクロスオリジンQUERYは必ずCORSプリフライトを発生させます。
- このメソッドを認識しないサーバー、プロキシ、WAFは、アプリケーションに届く前にリクエストを拒否する可能性があります。
検索エンドポイントはなぜGETとPOSTの間で行き詰まるのか?
複雑な検索には、GETもPOSTもうまく適合しません。GETはすべてのフィルターをURLに入れます。POSTはフィルターをボディに移せますが、そのリクエストが状態を変更する可能性があることを、すべての中間装置(intermediary)に伝えてしまいます。
RFC 9110は、すべての送信者と受信者に対し、少なくとも8,000オクテットのURIを扱えるよう求めています。これは最小値であって最大値ではありません。HTTPには上限が定められていないため、経路上のどのプロキシ、ゲートウェイ、サーバーも、それより長いURLを拒否する可能性があります。また、長いクエリ文字列はブラウザ履歴、サーバーログ、ブックマークにも残るため、フィルターの内容が漏洩するおそれがあります。
POSTはこうした問題を回避できますが、安全でも冪等でもありません。冪等なリクエストとは、1回送っても10回送っても、意図される効果が同じになるリクエストのことです。RFC 9110は、実際に冪等であると判断する手段がない限り、非冪等なリクエストを自動的にリトライしないようクライアントに求めています。その結果、キャッシュは一般にPOSTレスポンスを再利用せず、ゲートウェイも途中で失敗したPOSTをリトライしません。
HTTP QUERYメソッドとは?
RFC 10008において、QUERYリクエストは、リクエストボディに記述されたクエリを実行して結果を返すようサーバーに求めるものです。このとき、サーバー上の状態は一切変更されません。そのため、クライアントやプロキシは、途中で終わった最初の試行が状態を変更したかもしれないと心配せずに、QUERYを再送または再開できます。POSTではこれを安全に想定できません。以下はワイヤ上のリクエストです(各部分についてはHTTPリクエストの構造を参照してください)。
QUERY /products/search HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/json
{"category": "laptops", "price": {"max": 1500}, "brands": ["acme", "globex"], "sort": "price_asc"}
クエリは、ボディとそのメディアタイプの組み合わせによって定義されます。Content-Typeヘッダーが存在しないか、ボディの内容と一致しない場合、RFC 10008はサーバーにリクエストの拒否を求めています。RFC 10008のステータスコードに関するガイダンスでは、メディアタイプが指定されていない場合は400などの4xxコードを、送信されたメディアタイプをリソースがサポートしていない場合は415を返すよう示されています。
レスポンスは次のようになります。
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=60
Accept-Query: application/json
Content-Location: /products/search/results/7f3a
Location: /products/search/queries/7f3a
{"results": [ ... ]}
各ヘッダーはそれぞれ特定の役割を担っています。
- キャッシュキー: QUERYレスポンスはキャッシュ可能です。QUERYレスポンスを保存するキャッシュは、リクエストボディをキャッシュキーに含めます。同じURLへのQUERYリクエストでも、ボディが異なれば別の問い合わせになるためです。
Accept-Query: サーバーが受け付けるクエリ形式を列挙するためのレスポンスヘッダーで、メディアタイプのStructured Fieldリストとして記述されます。1つの値が、クエリ文字列にかかわらず、同じパスを持つサーバー上のすべてのURIに適用されます。- Content-LocationとLocation: QUERYレスポンスにおいて、Content-Locationはその特定のクエリの結果を識別します。Locationはクエリ自体のURIを示し、クライアントは後から通常のGETでこれを取得できます。これらのヘッダーの通常の振る舞いについては、HTTPレスポンスの中身に関する記事で解説しています。
例に含まれるCache-Controlは通常のキャッシュ設定であり、RFC 10008が必須としているものではありません。
現在QUERYはどこで使えるのか?
HTTP QUERYメソッドは、クライアントとサーバーの間で拒否されない限り、すでにいくつかのサーバーランタイム、フレームワーク、HTTPクライアントで動作します。
- Node.jsはhttpモジュールでQUERYを受け付けます。同梱のパーサーであるllhttpは
HTTP_QUERYを定義しており(Nodeに含まれるllhttp.hを参照)、llhttp 9.2以降このメソッドを認識しています。Node.jsの変更履歴によると、このバージョンのパーサーはv21.7.2で搭載され、v22以降およびv20.19.2にも含まれています。これらのバージョンではhttp.METHODSに'QUERY'が含まれ、サーバーのハンドラーではreq.method === 'QUERY'となります。 - GoのHTTPクライアントは、
http.NewRequestのメソッド文字列として有効な任意のメソッドトークンを受け付けるため、すでにQUERYを送信できます。
package main
import (
"log"
"net/http"
"strings"
)
func main() {
body := strings.NewReader(`{"category":"laptops","price":{"max":1500}}`)
req, err := http.NewRequest("QUERY", "https://api.example.com/products/search", body)
if err != nil {
log.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
}
- Laravel: Laravel 13.19.0では、QUERYメソッド用のクライアントメソッド
Http::query()と、テストヘルパーquery()/queryJson()が追加されました。ルートはRoute::match()でQUERYを受け付けられます。テストヘルパーはlaravel/framework PR #60662で追加されたものです。専用のRoute::query()ヘルパーは13.xではなくLaravelのmasterブランチにマージされたため、13.xのリリースには含まれていません。
ブラウザでは、同一オリジンであればスクリプトからfetch()でQUERYを送信できます。QUERYを追跡しているFetchのissue(whatwg/fetch#1938)が指摘しているとおり、Fetch StandardはQUERYを禁止しておらず、大文字・小文字の正規化も行いません。ブラウザは記述されたメソッド文字列をそのまま送信するため、必ず大文字で記述してください。
const res = await fetch('/products/search', {
method: 'QUERY',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: 'laptops', price: { max: 1500 } }),
});
const data = await res.json();
まだQUERYをサポートしていないものは?
現在、HTTP QUERYメソッドの利用を阻む要因は3つあります。HTMLフォームから送信できないこと、一部のサーバーやセキュリティレイヤーがメソッド自体を拒否すること、そしてクロスオリジン呼び出しにプリフライトが必要なことです。
フォーム: スクリプトからはfetch()でQUERYを送信できますが、フォームからは送信できません。method="query"のサポートはWHATWG HTML issue #12594で提案されています。このissueはまだオープンのままで、「needs implementer interest」ラベルが付いています。これが実現するまでは、次のマークアップは、
<form method="query" action="/products/search">
<input name="category" value="laptops">
<button>Search</button>
</form>
ボディが破棄されたGETとしてサーバーに届きます。ブラウザは未知のフォームメソッドをGETとして扱うためです。このissueのために作成された再現デモで、この挙動を確認できます。
サーバー、ゲートウェイ、WAF: このメソッドを認識しないホップは、いずれもリクエストを拒否する可能性があります。同じ再現デモには、たとえばLiteSpeedがアプリケーションの実行前にQUERYに対して400を返すことが記されています。リバースプロキシ、ロードバランサー、CDN、APIゲートウェイ、ファイアウォールは、それぞれ個別に確認する必要があります。
クロスオリジンのプリフライト: QUERYは、Fetch StandardのCORSセーフリストメソッドに含まれていません。そのため、クロスオリジンのQUERYは、どのようなヘッダーを伴っていても必ずプリフライトを発生させます。
OPTIONS /products/search HTTP/1.1
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
Access-Control-Allow-MethodsにQUERYが含まれていない場合、ブラウザは実際のリクエストをブロックします。
| 環境 | 状況 |
|---|---|
fetch()、同一オリジン | 動作する |
fetch()、クロスオリジン | QUERYを許可するプリフライトの後に動作する |
| HTMLフォーム | GETにフォールバックし、ボディは破棄される |
| Node.js http | 動作する(v20.19.2以降、v21.7.2以降、v22以降。llhttp 9.2がQUERYを認識) |
| Go HTTPクライアント | 動作する(カスタムメソッド文字列) |
| Laravel 13.19以降 | 動作する(クライアント、テスト、Route::match) |
| LiteSpeed | アプリケーション実行前に400で拒否される(#12594の再現デモで言及) |
| プロキシ、CDN、ゲートウェイ、WAF | 製品と設定による |
GraphQLはQUERYを使うべきか?
データを読み取るだけのGraphQLのクエリ操作は、安全かつ冪等でボディを運べるQUERYメソッドによく合います。一方、ミューテーションは状態を変更するため、引き続きPOSTを使うべきです。2つのAPIスタイルを比較検討している場合は、GraphQLとRESTの解説記事でトレードオフを解説しています。QUERYを採用する理由はどちらのスタイルでも同じで、検索型の読み取り処理にHTTPレベルのキャッシュとリトライを取り戻せることです。
QUERYがエンドツーエンドで動作するまでに必要なこと
QUERYがあらゆる環境で動作するには、ユーザーとハンドラーの間にあるすべてのレイヤーがこのメソッドを認識する必要があります。
- HTML仕様が
method="query"を受け入れ、ブラウザがそれを実装すること。 - 各ホップ(オリジンサーバー、リバースプロキシ、ロードバランサー、CDN、APIゲートウェイ、WAF)が、このメソッドを解析して転送すること。
- フレームワークがQUERYをハンドラーにルーティングし、そのボディを解析すること。
- キャッシュが、QUERYレスポンスを保存する前に、リクエストボディとContent-Typeをキャッシュキーに含めること。
CORSプリフライトは設計どおりの挙動です。RFC 10008はプリフライトを前提としており、whatwg/fetch#1938でもセーフリストの変更は求められていません。同issueで現在も議論されているのは、URLをキーとするFetchのHTTPキャッシュが、QUERY向けにボディをキーとするキャッシュをサポートすべきかどうかという点です。
まとめ
QUERYは、HTTPに長年存在していた不整合を解消します。検索リクエストは、キャッシュと安全なリトライを犠牲にすることなくボディを運べるようになります。スクリプト、Goクライアント、Laravelアプリケーションでは今すぐ利用できます。ただし、フォームからは利用できず、このメソッドを認識しないインフラは依然としてリクエストを拒否する可能性があります。エンドポイントを移行する前に、CDN、ゲートウェイ、WAFを含む本番環境の全経路で実際にQUERYリクエストを送信して確認してください。また、すべてのホップがQUERYを通過させるようになるまでは、POSTエンドポイントを稼働させ続けてください。
よくある質問(FAQ)
QUERYを使わずに、GETでリクエストボディを送ればよいのではないですか?
GETのボディは信頼性に欠けます。RFC 9110のセクション9.3.1では、GETのボディには一般的に定義された意味がなく、リクエストの対象や意味を変えることはできないとされています。GETのボディはリクエストスマグリングに悪用される可能性があるため、そのようなリクエストを一律に拒否するサーバーもあります。また、ボディは標準のキャッシュキーにも含まれません。QUERYでは、ボディそのものがクエリとなり、キャッシュキーにも含まれます。
QUERYメソッドとWebDAVのSEARCHメソッドの違いは何ですか?
SEARCHは2008年にRFC 5323で定義されたWebDAVのメソッドで、XMLのクエリ文法を用いてDAVリソースを検索するためのものです。一方、QUERYはあらゆるクエリ形式に対応する汎用的なHTTPメソッドです。どちらも安全なメソッドですが、SEARCHはWebDAV以外には普及しませんでした。QUERY仕様の初期ドラフトではSEARCHという名前が使われていました。著者らがQUERYに名称を変更した理由は、SEARCHをはじめとする既存の安全なメソッドがWebDAV由来で汎用的なXMLメディアタイプに依存していること、そしてQUERYという名前がURIのクエリ部分と一致することです。
QUERYエンドポイントをOpenAPIで記述できますか?
はい、2025年9月にリリースされたOpenAPI 3.2.0から可能です。このバージョンではqueryメソッドが組み込みでサポートされ、QUERYオペレーションでもgetやpostと同様にrequestBodyスキーマとresponsesを宣言できます。その他の非標準メソッドは、新たに追加されたadditionalOperationsマップに記述します。OpenAPI 3.0および3.1にはQUERY用のフィールドがなく、3.2へのツールの対応状況もジェネレーターによって異なります。
QUERYをサポートしていないサーバーは、どのステータスコードを返しますか?
RFC 9110では、リクエストメソッドを認識しないサーバーは501 Not Implementedを返すべきとされています。QUERYを認識していても特定のリソースでは許可していないサーバーは、405 Method Not Allowedを返し、サポートするメソッドを列挙したAllowヘッダーを付与します。プロキシやWAFは、単なる400を返すなど異なる形で失敗する場合があるため、POSTへのフォールバック処理ではこれら3つすべてに対応する必要があります。