先日、こんな投稿を見かけました。
¡Histórico! Nuevo método HTTP para el estándar.
Se llama QUERY y es una alternativa a GET y POST
(新しいHTTPメソッドが標準に。名前は QUERY で、GETとPOSTの代替になる)
新しいHTTPメソッド QUERY が、ついに "Proposed Standard" になったという話題です。
仕様は RFC 10008 として公開されています。
「GETとPOSTがあれば足りるのでは?」と思う方もいるかもしれませんが、実は両者のいいとこ取りをしたいときに、地味に困る場面があります。
今回はその QUERY メソッドがどんなものなのかを整理しつつ、Hono + Bun で実際にAPIを実装して動かしてみます。
QUERYメソッドとは
ざっくり言うと、 「ボディを持てる、安全なGET」 です。
これまでの GET / POST と比べると、こういう位置づけになります。
| GET | POST | QUERY | |
|---|---|---|---|
| リソースを変更しない | ✅ | ❌ | ✅ |
| リクエストボディを使える | ❌(※) | ✅ | ✅ |
| 冪等 | ✅ | ❌ | ✅ |
| レスポンスをキャッシュできる | ✅ | ❌ | ✅ |
つまり、
-
GETのように リソースの状態を変更しない -
POSTのように ボディでリクエストを渡せる - しかも レスポンスをキャッシュできる
という、検索(query)のためにあつらえたようなメソッドです。
GET でもボディを付けること自体は技術的に可能ですが、「GETのボディには意味を持たせてはいけない」とされており、プロキシやキャッシュに握りつぶされることがあります。そのため実質的には使えない、というのが(※)の意図です。
なぜ必要なのか
複雑な検索条件をサーバーに渡したい、というケースを考えてみます。
GET でやろうとすると、条件はすべてクエリ文字列に載せることになります。
GET /blogs?query=入門&limit=2&tags=TypeScript,Bun&sort=id
これはこれで動くのですが、
- 条件がネストした構造(配列やオブジェクト)になると表現しづらい
- URLの長さ制限にひっかかることがある
- 検索条件がURLに丸見えになる
といった問題が出てきます。
かといって POST /blogs/search のようにしてしまうと、今度は 「これは検索なのに、意味的には状態変更(POST)に見える」 という気持ち悪さが残りますし、レスポンスをキャッシュすることもできません。
QUERY は、まさにこの 「ボディで複雑な条件を渡したいが、あくまで安全な読み取りである」 というギャップを埋めてくれるメソッド、というわけです。
実装してみる
前置きが長くなりましたが、実際に手を動かしてみます。
今回はランタイムに Bun、Webフレームワークに Hono を使いました。
なお、今回のコードは以下のリポジトリに置いてあります。
bun create hono@latest query-method-api
cd query-method-api
bun add hono zod @hono/zod-validator
ルーティング
Honoでは app.get / app.post といったお馴染みのメソッドに加えて、任意のメソッドを登録できる app.on(method, path, handler) が用意されています。
QUERY のような標準にないメソッドも、これで素直に登録できます。
import { Hono } from "hono";
import { queryBlogsHandlers } from "./handlers/blogs";
const app = new Hono();
app.get("/ping", (c) => c.text("pong"));
app.options("/blogs", (c) => {
return c.body(null, 204, {
Allow: "QUERY, OPTIONS",
"Accept-Query": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "QUERY, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Accept-Query",
});
});
app.on("QUERY", "/blogs", ...queryBlogsHandlers);
export default app;
ポイントは app.options で OPTIONS を返しているところです。
QUERY はまだ新しいメソッドなので、クライアントは「このエンドポイントは QUERY を受け付けるのか?」を事前に知りたくなります。
そのために、OPTIONS へのレスポンスで Allow: QUERY, OPTIONS を返し、さらに Accept-Query: application/json で「ボディはJSONで受け付けますよ」と宣言しています。
Accept-Query は、サーバーが受け付けられるクエリのフォーマットを示すためのレスポンスヘッダです。Accept-Post のQUERY版、とイメージするとわかりやすいです。
ハンドラ
検索本体のハンドラです。@hono/zod-validator を使って、ボディのバリデーションもあわせて行っています。
import { zValidator } from "@hono/zod-validator";
import { createFactory } from "hono/factory";
import * as z from "zod";
import { allBlogs } from "../data";
const RequestJsonSchema = z
.object({
query: z.string().optional(),
limit: z.number().int().optional(),
})
.strict();
const factory = createFactory();
export const queryBlogsHandlers = factory.createHandlers(
async (c, next) => {
const contentType = c.req.header("content-type") || "";
if (contentType && !contentType.includes("application/json")) {
return c.json({ error: "Unsupported Media Type" }, 415);
}
await next();
},
zValidator("json", RequestJsonSchema, (result, c) => {
if (!result.success) {
return c.json({ error: "Invalid request body" }, 400);
}
}),
async (c) => {
const { query, limit } = c.req.valid("json");
return c.json(allBlogs.search(query).sortById().take(limit), 200, {
"Accept-Query": "application/json",
});
},
);
やっていることを上から順に並べると、
-
Content-TypeがJSONでなければ 415 (Unsupported Media Type) を返す - ボディがスキーマに合わなければ 400 (Bad Request) を返す
- 問題なければ
queryで絞り込み →idでソート →limitで件数制限して返す
という流れです。
QUERY はボディを持てるので、こうして JSONで検索条件を受け取れる のが GET との一番大きな違いですね。
ドメイン
検索ロジックはドメイン側に寄せています。特別なことはしておらず、素直なイミュータブルな実装です。
export class Blog {
constructor(
public readonly id: number,
public readonly title: string,
public readonly content: string,
) {}
}
export class Blogs {
constructor(private readonly blogs: Blog[]) {}
search(query?: string): Blogs {
if (!query) return this;
return new Blogs(this.blogs.filter((b) => b.title.includes(query)));
}
sortById(): Blogs {
return new Blogs([...this.blogs].sort((a, b) => a.id - b.id));
}
take(limit?: number): Blogs {
if (!limit) return this;
return new Blogs(this.blogs.slice(0, limit));
}
}
search / sortById / take がそれぞれ新しい Blogs を返すので、allBlogs.search(query).sortById().take(limit) のようにメソッドチェーンで書けるのが気持ちいいところです。
データ自体は src/data.ts にハードコードした10件のブログを使っています。
動かしてみる
サーバーを起動します。
bun run dev
# open http://localhost:3000
まずはOPTIONSで確認
このエンドポイントが QUERY を受け付けるか、OPTIONS で聞いてみます。
$ curl -s -X OPTIONS localhost:3000/blogs -i
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: QUERY, OPTIONS
Access-Control-Allow-Headers: Content-Type, Accept-Query
Allow: QUERY, OPTIONS
Accept-Query: application/json
Content-Length: 0
Allow: QUERY, OPTIONS と Accept-Query: application/json が返ってきました。
「このエンドポイントは QUERY を、JSONボディで受け付ける」と分かります ![]()
QUERYで検索する
いよいよ本命です。curl の -X QUERY でメソッドを指定し、-d でボディを渡します。
# 全件取得(空のJSONを渡す)
$ curl -s -X QUERY localhost:3000/blogs \
-H "Content-Type: application/json" \
-d '{}'
{"blogs":[{"id":1,"title":"Clojure入門",...},{"id":2,"title":"TypeScriptの型システム",...}, ...]}
# 「入門」で検索して2件だけ取得
$ curl -s -X QUERY localhost:3000/blogs \
-H "Content-Type: application/json" \
-d '{"query":"入門","limit":2}'
{"blogs":[
{"id":1,"title":"Clojure入門","content":"こんにちは。Clojureの世界へようこそ。"},
{"id":5,"title":"GraphQL入門","content":"GraphQLはAPIの新しい形です。"}
]}
GET ではやりづらかった 「JSONボディで検索条件を渡す」 が、ごく自然にできました。
curl でカスタムHTTPメソッドを使うには -X(--request) を使います。QUERY 自体は比較的新しいメソッドですが、curl はメソッド名を文字列として送るだけなので、特別な対応がなくてもそのまま送れます。
異常系も確認
バリデーションもちゃんと効いています。
# スキーマにないキーを渡す → 400
$ curl -s -o /dev/null -w "%{http_code}\n" -X QUERY localhost:3000/blogs \
-H "Content-Type: application/json" -d '{"foo":1}'
400
# JSON以外のContent-Type → 415
$ curl -s -o /dev/null -w "%{http_code}\n" -X QUERY localhost:3000/blogs \
-H "Content-Type: text/plain" -d 'hi'
415
.strict() を付けているので、未知のキーが混ざっていると弾いてくれます。
まとめ
-
QUERYは 「ボディを持てる、安全で冪等なGET」 という位置づけの新しいHTTPメソッド -
GETの「状態を変えない・キャッシュできる」と、POSTの「ボディで複雑な条件を渡せる」のいいとこ取り - Honoでは
app.on("QUERY", path, handler)で素直に実装できる -
OPTIONSでAllow/Accept-Queryを返すと、クライアントが対応状況を確認できて親切 - ただしブラウザ・プロキシ・CDNの対応はこれからなので、当面は
POSTとの併用が現実的
新しいメソッドが標準に加わるのは久しぶりで、書いていて素直にワクワクしました。
検索系のエンドポイントを設計するときの選択肢として、頭の片隅に置いておくとよさそうです ![]()