先前,我看到了這樣的一則貼文。
¡Histórico! 新的 HTTP 標準方法。
名稱叫 QUERY,是 GET 和 POST 的替代方案
(新的 HTTP 方法成為標準。名字是 QUERY,會成為 GET 和 POST 的替代)
這是在說新的 HTTP 方法 QUERY 終於成為了「Proposed Standard」。
規格已經以 RFC 10008 的形式公開。
或許有人會想:「有 GET 和 POST 不就夠了嗎?」但其實在想同時兼顧兩者優點的情境下,會有一些不起眼卻很困擾的場面。
這次我會整理一下 QUERY 方法到底是什麼,並實際用 Hono + Bun 來實作並跑看看 API。
簡單來說,就是 「可以帶 request body 的、安全版 GET」。
和既有的 GET / POST 相比,定位大致如下:
GETPOSTQUERY不會改變資源✅❌✅可使用 request body❌(※)✅✅冪等✅❌✅回應可快取✅❌✅也就是說:
GET 一樣 不會改變資源狀態POST 一樣 可以透過 body 傳遞請求是一個幾乎就是為了搜尋(query)而設計的 method。
技術上來說,GET 也不是不能加 body,但規範上是「GET 的 body 不應該賦予意義」,而且可能會被 proxy 或快取系統直接忽略。因此實務上幾乎不能用,這就是 (※) 的意思。
來想像一個要把複雜搜尋條件傳給伺服器的情境。
如果用 GET 來做,條件就必須全部放在 query string 裡。
GET /blogs?query=入門&limit=2&tags=TypeScript,Bun&sort=id
這樣雖然可以運作,但會有這些問題:
反過來如果做成 POST /blogs/search,又會有 「明明是搜尋,語意上卻看起來像會改變狀態的 POST」 這種不協調感,而且也沒辦法快取回應。
QUERY 正是用來填補這個落差的 method:想用 body 傳複雜條件,但本質上仍是安全的讀取操作。
前面鋪陳有點長,接著實際動手。
這次 runtime 用 Bun,Web framework 用 Hono。
另外,這次的程式碼放在以下 repository:
bun create hono@latest query-method-api
cd query-method-api
bun add hono zod @hono/zod-validator
Hono 除了 app.get / app.post 這些熟悉的方法之外,也提供了可以註冊任意 method 的 app.on(method, path, handler)。
像 QUERY 這種尚未普及的新 method,也可以很自然地註冊。
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.options 回傳 OPTIONS。
QUERY 還是個很新的 method,客戶端自然會想先知道「這個 endpoint 接不接受 QUERY?」
因此我們在 OPTIONS 的 response 中回傳 Allow: QUERY, OPTIONS,並且再用 Accept-Query: application/json 宣告「body 會以 JSON 接收」。
Accept-Query 是用來表示伺服器可接受之 query 格式的 response header。可以把它想成 Accept-Post 的 QUERY 版本。
這裡是搜尋本體的 handler。使用 @hono/zod-validator 一併做 body validation。
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",
});
},
);
從上往下看,流程是:
Content-Type 不是 JSON,就回傳 415(Unsupported Media Type)query 篩選 → 用 id 排序 → 用 limit 限制數量後回傳QUERY 可以帶 body,因此能像這樣 用 JSON 接收搜尋條件,這正是它和 GET 最大的差異。
搜尋邏輯放在 domain 層。沒有做什麼特別的事,就是很單純的 immutable 實作。
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) 這樣串接 method chain,寫起來很順。
資料則是使用 src/data.ts 裡硬編碼的 10 筆 blog。
啟動伺服器。
bun run dev
# open http://localhost:3000
先用 OPTIONS 問看看這個 endpoint 是否接受 QUERY。
$ 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。
這表示「這個 endpoint 接受 QUERY,且 body 格式是 JSON」。
重頭戲來了。用 curl 的 -X QUERY 指定 method,並用 -d 帶入 body。
# 取得全部資料(傳空 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 body 傳搜尋條件」,現在可以非常自然地做到。
在 curl 中使用自訂 HTTP method,要用 -X(--request)。QUERY 雖然是相對新的 method,但 curl 本質上只是把 method 名稱當字串送出,所以不需要特別支援也能直接送。
驗證也有正常運作。
# 傳入 schema 以外的 key → 400
$ curl -s -o /dev/null -w "%{http_code}\n" -X QUERY localhost:3000/blogs \
-H "Content-Type: application/json" -d '{"foo":1}'
400
# Content-Type 不是 JSON → 415
$ curl -s -o /dev/null -w "%{http_code}\n" -X QUERY localhost:3000/blogs \
-H "Content-Type: text/plain" -d 'hi'
415
因為有加上 .strict(),所以只要混入未知 key 就會被擋下來。
QUERY 是一種新的 HTTP 方法,定位為 「可以帶 body 的、安全且冪等的 GET」GET 的「不改變狀態、可快取」與 POST 的「可用 body 傳遞複雜條件」優點app.on("QUERY", path, handler) 很自然地實作OPTIONS 回傳 Allow / Accept-Query,能讓客戶端清楚知道支援狀況,做法很貼心POST 並用仍是比較務實的做法能看到新的 method 納入標準,老實說寫起來還挺令人興奮的。
未來在設計搜尋型 endpoint 時,這會是值得放在腦中的一個選項。