8
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

新しいHTTPメソッド「QUERY」をHono + Bunで実装してみる

8
Last updated at Posted at 2026-07-03

先日、こんな投稿を見かけました。

¡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 のような標準にないメソッドも、これで素直に登録できます。

src/index.ts
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.optionsOPTIONS を返しているところです。

QUERY はまだ新しいメソッドなので、クライアントは「このエンドポイントは QUERY を受け付けるのか?」を事前に知りたくなります。
そのために、OPTIONS へのレスポンスで Allow: QUERY, OPTIONS を返し、さらに Accept-Query: application/json で「ボディはJSONで受け付けますよ」と宣言しています。

Accept-Query は、サーバーが受け付けられるクエリのフォーマットを示すためのレスポンスヘッダです。Accept-Post のQUERY版、とイメージするとわかりやすいです。

ハンドラ

検索本体のハンドラです。@hono/zod-validator を使って、ボディのバリデーションもあわせて行っています。

src/handlers/blogs.ts
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",
    });
  },
);

やっていることを上から順に並べると、

  1. Content-Type がJSONでなければ 415 (Unsupported Media Type) を返す
  2. ボディがスキーマに合わなければ 400 (Bad Request) を返す
  3. 問題なければ query で絞り込み → id でソート → limit で件数制限して返す

という流れです。
QUERY はボディを持てるので、こうして JSONで検索条件を受け取れる のが GET との一番大きな違いですね。

ドメイン

検索ロジックはドメイン側に寄せています。特別なことはしておらず、素直なイミュータブルな実装です。

src/domains/blog.ts
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, OPTIONSAccept-Query: application/json が返ってきました。
「このエンドポイントは QUERY を、JSONボディで受け付ける」と分かります :thumbsup:

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) で素直に実装できる
  • OPTIONSAllow / Accept-Query を返すと、クライアントが対応状況を確認できて親切
  • ただしブラウザ・プロキシ・CDNの対応はこれからなので、当面は POST との併用が現実的

新しいメソッドが標準に加わるのは久しぶりで、書いていて素直にワクワクしました。
検索系のエンドポイントを設計するときの選択肢として、頭の片隅に置いておくとよさそうです :thumbsup:

参考

8
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?