From 9947b7737607e05600d571cdb806ea9affcd6a05 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 9 Jul 2026 22:55:57 +0000 Subject: [PATCH 1/2] feat: `get` option for `defineQueryHandler` (GET equivalence) Co-Authored-By: Claude Opus 4.8 --- docs/2.utils/1.request.md | 37 +++++--- docs/4.examples/handle-query.md | 32 ++++--- examples/query.mjs | 97 ++++++-------------- src/index.ts | 7 +- src/utils/internal/query-get.ts | 87 ++++++++++++++++++ src/utils/query.ts | 95 ++++++++++++++++++-- test/query.test.ts | 154 ++++++++++++++++++++++++++++++++ 7 files changed, 409 insertions(+), 100 deletions(-) create mode 100644 src/utils/internal/query-get.ts diff --git a/docs/2.utils/1.request.md b/docs/2.utils/1.request.md index ed2b666c9..b355fea43 100644 --- a/docs/2.utils/1.request.md +++ b/docs/2.utils/1.request.md @@ -133,16 +133,33 @@ Requests are rejected with `405` (non-`QUERY` method), `400` (missing `Content-T **Example:** ```ts -app.query( - "/books", - defineQueryHandler({ - formats: ["application/sql", "application/jsonpath"], - handler: async (event, { format }) => { - const query = await readBody(event, { type: "text" }); - return runQuery(format, query); - }, - }), -); +app.query("/books", defineQueryHandler({ + formats: ["application/sql", "application/jsonpath"], + handler: async (event, { format }) => { + const query = await readBody(event, { type: "text" }); + return runQuery(format, query); + }, +})); +With the `get` option, the same handler also serves an equivalent, +HTTP-cacheable GET (RFC 10008 §2.3): the query is read from the given URL +search param (and the format from `?format=`), the handler receives the +resolved `query` in its context on both paths, and successful QUERY +responses advertise the equivalent GET via `Content-Location` — preserving +existing search params, and skipped when the URL would exceed 2048 chars. +Register the handler for both methods; GET-path rejections are `400`. +``` + +**Example:** + +```ts +const searchBooks = defineQueryHandler({ + formats: ["application/sql", "application/jsonpath"], + get: "q", + handler: (event, { format, query }) => runQuery(format, query), +}); +app.get("/books", searchBooks).query("/books", searchBooks); +// QUERY /books -> 200 + Content-Location: /books?q=&format= +// GET /books?q=... -> same result, ordinary HTTP caching applies ``` ### `requireContentType(event, acceptedTypes)` diff --git a/docs/4.examples/handle-query.md b/docs/4.examples/handle-query.md index 67436dabd..43363a796 100644 --- a/docs/4.examples/handle-query.md +++ b/docs/4.examples/handle-query.md @@ -31,6 +31,24 @@ app.query( Formats may use wildcards (`application/*`, `*/*`) — `format` is always the concrete request media type. The sections below show the lower-level utilities it builds on, for when you need custom behavior. +## Offer a Cacheable `GET` Equivalent + +A `QUERY` response is not URL-addressable (and content-keyed `QUERY` caching is not deployed in practice), so browsers and CDNs won't reuse it. RFC 10008 (§2.3) suggests advertising an equivalent, cacheable `GET` via the `Content-Location` header. Pass `get` to `defineQueryHandler` and register the handler for both methods — the _same_ handler serves the advertised `GET`, so no server-side result store is needed: + +```ts +const searchBooks = defineQueryHandler({ + formats: ["application/sql", "application/jsonpath"], + get: "q", + handler: (event, { format, query }) => runQuery(format, query), +}); + +app.get("/books", searchBooks).query("/books", searchBooks); +// QUERY /books -> 200 + Content-Location: /books?q=&format= +// GET /books?q=... -> same result, ordinary HTTP caching applies +``` + +With `get` set, the handler receives the resolved `query` in its context on both paths (read from the body on `QUERY`, from the URL param on `GET`/`HEAD`). On `GET`, the format comes from `?format=` (customizable via `get: { param, formatParam }`) and may be omitted when exactly one concrete format is accepted; rejections on the `GET` path are `400`. `Content-Location` preserves the request's existing search params and is skipped when the equivalent URL would exceed 2048 characters — very long queries are the reason `QUERY` exists. + ## Register a `QUERY` Handler Read the request body just like you would for a `POST`: @@ -74,20 +92,6 @@ app.query("/books", async (event) => { }); ``` -## Offer a Cacheable `GET` Alternative - -A `QUERY` response is not addressable by URL, so browsers and CDNs can't cache it. RFC 10008 suggests pointing clients at an equivalent, cacheable `GET` via the `Content-Location` header. Stash the result under a stable id and let a client repeat the query with an ordinary, HTTP-cacheable `GET`: - -```ts -app.query("/books", async (event) => { - const result = runQuery(type, query); - const id = queryId(type, query); // stable hash of the query - cache.set(id, result); - event.res.headers.set("content-location", `/books/${id}`); - return result; -}); -``` - ## Full Example A self-contained, runnable demo — a `/books` resource that accepts SQL-ish and JSONPath queries, validates the `Content-Type`, and advertises a cacheable `GET` alternative. It also serves a small interactive page at `/`. diff --git a/examples/query.mjs b/examples/query.mjs index 5a10b11ce..a169f14ee 100644 --- a/examples/query.mjs +++ b/examples/query.mjs @@ -1,13 +1,4 @@ -import { - H3, - serve, - html, - getRouterParam, - appendAcceptQuery, - defineQueryHandler, - readBody, - HTTPError, -} from "../dist/_entries/node.mjs"; +import { H3, serve, html, defineQueryHandler } from "../dist/_entries/node.mjs"; // The HTTP QUERY method (RFC 10008) is a safe, idempotent request that carries a // query in its BODY — useful when a query is too large or too structured for the @@ -22,10 +13,6 @@ const BOOKS = [ const ACCEPTED = ["application/sql", "application/jsonpath"]; -// Results of past queries, keyed by a stable id, so the same query can be -// retrieved again through a cacheable GET (see `Content-Location` below). -const cache = new Map(); - // Run a query and return the matching books. function runQuery(type, query) { if (type === "application/jsonpath") { @@ -38,15 +25,6 @@ function runQuery(type, query) { return author ? BOOKS.filter((b) => b.author === author) : BOOKS; } -// Stable id (FNV-1a) so identical queries map to the same cacheable URL. -function queryId(type, query) { - let h = 0x81_1c_9d_c5; - for (const ch of `${type}\n${query}`) { - h = Math.imul(h ^ ch.charCodeAt(0), 0x01_00_01_93); - } - return (h >>> 0).toString(16); -} - // A minimal self-contained page that sends QUERY requests to /books via fetch. const page = html` H3 QUERY Demo @@ -96,66 +74,49 @@ const page = html` }); `; +// `defineQueryHandler` wires up the RFC 10008 ceremony: it advertises the +// accepted formats via `Accept-Query` on every response (including errors), +// validates the request `Content-Type` (throws 400/415/422), and passes the +// matched media type and the query to the handler. +// +// The `get` option makes the same handler serve an equivalent, HTTP-cacheable +// GET (`/books?q=&format=`): successful QUERY responses +// advertise it via `Content-Location` (RFC 10008 §2.3), and a client +// repeating the query can just GET that URL — no server-side result store. +const searchBooks = defineQueryHandler({ + formats: ACCEPTED, + get: "q", + handler: (event, { format, query }) => { + if (event.req.method === "GET") { + // Unlike a QUERY response, this GET is safe for browsers/CDNs to cache. + event.res.headers.set("cache-control", "public, max-age=60"); + } + return runQuery(format, query.trim()); + }, +}); + export const app = new H3(); app .get("/", () => page) - .get("/books", (event) => { - // Advertise the accepted query formats on a plain GET too, so clients can - // discover them before sending a QUERY request. - appendAcceptQuery(event, ACCEPTED); - return "Send a QUERY request to /books with a SQL or JSONPath body."; - }) - .get("/books/:id", (event) => { - // The cacheable GET alternative advertised by the QUERY response below. - const result = cache.get(getRouterParam(event, "id")); - if (!result) { - throw new HTTPError({ status: 404, message: "Unknown query id" }); - } - // Unlike a QUERY response, this GET is safe for browsers/CDNs to cache. - event.res.headers.set("cache-control", "public, max-age=60"); - return result; - }) - .query( - "/books", - // `defineQueryHandler` wires up the RFC 10008 ceremony: it advertises the - // accepted formats via `Accept-Query` on every response (including 415 - // errors), validates the request `Content-Type` (throws 400/415/422), and - // passes the matched media type to the handler as `format`. - defineQueryHandler({ - formats: ACCEPTED, - handler: async (event, { format }) => { - const query = (await readBody(event, { type: "text" }))?.trim() ?? ""; - const result = runQuery(format, query); - - // Offer a cacheable GET alternative for this exact query (RFC 10008): - // stash the result under a stable id and point the client at it via - // `Content-Location`. A client repeating this query can just GET that - // URL and benefit from ordinary HTTP caching. - const id = queryId(format, query); - cache.set(id, result); - event.res.headers.set("content-location", `/books/${id}`); - - return result; - }, - }), - ); + .get("/books", searchBooks) + .query("/books", searchBooks); serve(app); // Or try it from the terminal: -// # Discover the accepted query formats: +// # Discover the accepted query formats (every response advertises them): // curl -i http://localhost:3000/books -// # -> Accept-Query: application/sql, application/jsonpath +// # -> 400, Accept-Query: application/sql, application/jsonpath // // # SQL query (note the `Content-Location` header in the response): // curl -i -X QUERY http://localhost:3000/books \ // -H "Content-Type: application/sql" \ // --data "SELECT * FROM books WHERE author = 'Simpson'" -// # -> 200, Content-Location: /books/ +// # -> 200, Content-Location: /books?q=SELECT+...&format=application%2Fsql // -// # Re-fetch the same result via the cacheable GET alternative: -// curl -i http://localhost:3000/books/ +// # Re-run the same query via the equivalent, cacheable GET: +// curl -i "http://localhost:3000/books?q=SELECT%20*%20FROM%20books%20WHERE%20author%20=%20'Simpson'&format=application/sql" // # -> 200, Cache-Control: public, max-age=60 // // # JSONPath query: diff --git a/src/index.ts b/src/index.ts index 80c1b9ca0..ff96b0852 100644 --- a/src/index.ts +++ b/src/index.ts @@ -113,7 +113,12 @@ export { // Query (RFC 10008 HTTP QUERY method) -export { appendAcceptQuery, requireContentType, defineQueryHandler } from "./utils/query.ts"; +export { + appendAcceptQuery, + requireContentType, + defineQueryHandler, + type QueryHandlerGetOptions, +} from "./utils/query.ts"; // Middleware diff --git a/src/utils/internal/query-get.ts b/src/utils/internal/query-get.ts new file mode 100644 index 000000000..a682a424c --- /dev/null +++ b/src/utils/internal/query-get.ts @@ -0,0 +1,87 @@ +// Internal helpers for `defineQueryHandler`'s GET equivalence (RFC 10008 §2.3). + +import { HTTPError } from "../../error.ts"; +import { baseMediaType, mediaTypeMatches } from "./media-type.ts"; + +import type { H3Event } from "../../event.ts"; + +export interface QueryGetOptions { + param: string; + formatParam: string; +} + +// Long queries are the reason QUERY exists: skip the `Content-Location` +// advertisement when the equivalent GET URL would risk hitting URL length +// limits in browsers and intermediaries. +const MAX_CONTENT_LOCATION_LENGTH = 2048; + +/** + * Resolve the query and its format from the URL search params of a GET/HEAD + * request equivalent to a QUERY request (RFC 10008 §2.3). + * + * All rejections are `400 Bad Request`: unlike the QUERY path, a GET carries + * no content for `415`/`422` to apply to. + */ +export function resolveGetQuery( + event: H3Event, + get: QueryGetOptions, + formats: string[], + defaultFormat: string | undefined, +): { format: string; query: string } { + const query = event.url.searchParams.get(get.param); + if (query === null) { + throw new HTTPError({ + status: 400, + statusText: "Bad Request", + message: `Missing \`?${get.param}=\` query parameter`, + }); + } + + const formatParam = event.url.searchParams.get(get.formatParam); + let format: string; + if (formatParam) { + format = baseMediaType(formatParam); + if (!formats.some((type) => mediaTypeMatches(format, baseMediaType(type)))) { + throw new HTTPError({ + status: 400, + statusText: "Bad Request", + message: `Unsupported query format: ${format}. Expected one of: ${formats.join(", ")}`, + }); + } + } else if (defaultFormat) { + format = defaultFormat; + } else { + // Multiple (or wildcard) accepted formats: defaulting silently would + // change the query semantics (e.g. SQL vs JSONPath), so require the param. + throw new HTTPError({ + status: 400, + statusText: "Bad Request", + message: `Missing \`?${get.formatParam}=\` query parameter. Expected one of: ${formats.join(", ")}`, + }); + } + + return { format, query }; +} + +/** + * Advertise the equivalent, HTTP-cacheable GET for this exact query on a + * QUERY response via `Content-Location` (RFC 10008 §2.3), preserving the + * request's existing search params. + */ +export function setQueryContentLocation( + event: H3Event, + get: QueryGetOptions, + query: string, + format: string, + defaultFormat: string | undefined, +): void { + const params = new URLSearchParams(event.url.search); + params.set(get.param, query); + if (!defaultFormat) { + params.set(get.formatParam, format); + } + const location = `${event.url.pathname}?${params}`; + if (location.length <= MAX_CONTENT_LOCATION_LENGTH) { + event.res.headers.set("content-location", location); + } +} diff --git a/src/utils/query.ts b/src/utils/query.ts index 949fac20b..eff078bef 100644 --- a/src/utils/query.ts +++ b/src/utils/query.ts @@ -1,6 +1,7 @@ import { HTTPError } from "../error.ts"; import { defineHandler } from "../handler.ts"; import { serializeAcceptQuery, baseMediaType, mediaTypeMatches } from "./internal/media-type.ts"; +import { resolveGetQuery, setQueryContentLocation } from "./internal/query-get.ts"; import type { H3Event, HTTPEvent } from "../event.ts"; import type { EventHandlerObject, EventHandlerWithFetch } from "../types/handler.ts"; @@ -96,6 +97,44 @@ export function requireContentType(event: HTTPEvent, acceptedTypes: string | str }); } +/** + * Options for `defineQueryHandler`'s GET equivalence. + */ +export interface QueryHandlerGetOptions { + /** + * URL search param carrying the query on GET/HEAD requests. + */ + param: string; + + /** + * URL search param selecting the query format on GET/HEAD requests + * (default: `"format"`). It may be omitted by clients when exactly one + * concrete (non-wildcard) format is accepted. + */ + formatParam?: string; +} + +type QueryHandlerBase = Omit & { + formats: string[]; +}; + +export function defineQueryHandler( + def: QueryHandlerBase & { + get: string | QueryHandlerGetOptions; + handler: ( + event: H3Event, + context: { format: string; query: string }, + ) => unknown | Promise; + }, +): EventHandlerWithFetch; + +export function defineQueryHandler( + def: QueryHandlerBase & { + get?: undefined; + handler: (event: H3Event, context: { format: string }) => unknown | Promise; + }, +): EventHandlerWithFetch; + /** * Define an HTTP `QUERY` method handler (RFC 10008) with the accepted query * `formats` enforced and advertised. @@ -119,12 +158,30 @@ export function requireContentType(event: HTTPEvent, acceptedTypes: string | str * }, * })); * - * @param def Handler options: the accepted `formats`, the `handler`, plus optional `middleware` and `meta`. + * With the `get` option, the same handler also serves an equivalent, + * HTTP-cacheable GET (RFC 10008 §2.3): the query is read from the given URL + * search param (and the format from `?format=`), the handler receives the + * resolved `query` in its context on both paths, and successful QUERY + * responses advertise the equivalent GET via `Content-Location` — preserving + * existing search params, and skipped when the URL would exceed 2048 chars. + * Register the handler for both methods; GET-path rejections are `400`. + * + * @example + * const searchBooks = defineQueryHandler({ + * formats: ["application/sql", "application/jsonpath"], + * get: "q", + * handler: (event, { format, query }) => runQuery(format, query), + * }); + * app.get("/books", searchBooks).query("/books", searchBooks); + * // QUERY /books -> 200 + Content-Location: /books?q=&format= + * // GET /books?q=... -> same result, ordinary HTTP caching applies + * + * @param def Handler options: the accepted `formats`, the `handler`, optional `get` equivalence, plus optional `middleware` and `meta`. */ export function defineQueryHandler( - def: Omit & { - formats: string[]; - handler: (event: H3Event, context: { format: string }) => unknown | Promise; + def: QueryHandlerBase & { + get?: string | QueryHandlerGetOptions; + handler: (event: H3Event, context: any) => unknown | Promise; }, ): EventHandlerWithFetch { if (def.formats.length === 0) { @@ -135,6 +192,17 @@ export function defineQueryHandler( // avoids re-serializing on every request. const acceptQuery = serializeAcceptQuery(def.formats); + const get = def.get + ? { formatParam: "format", ...(typeof def.get === "string" ? { param: def.get } : def.get) } + : undefined; + + // With a single concrete (non-wildcard) accepted format, GET requests may + // omit the format param and `Content-Location` doesn't need to carry it. + const defaultFormat = + def.formats.length === 1 && !def.formats[0].includes("*") + ? baseMediaType(def.formats[0]) + : undefined; + return defineHandler({ ...def, handler: function _queryHandler(event) { @@ -144,18 +212,31 @@ export function defineQueryHandler( event.res.headers.append("accept-query", acceptQuery); event.res.errHeaders.append("accept-query", acceptQuery); - if (event.req.method !== "QUERY") { + const method = event.req.method; + if (method !== "QUERY" && !(get && (method === "GET" || method === "HEAD"))) { throw new HTTPError({ status: 405, statusText: "Method Not Allowed", - headers: { allow: "QUERY" }, + headers: { allow: get ? "GET, HEAD, QUERY" : "QUERY" }, }); } + if (method !== "QUERY") { + // GET/HEAD equivalent of a QUERY request (RFC 10008 §2.3). + return def.handler(event, resolveGetQuery(event, get!, def.formats, defaultFormat)); + } + // Throws 400 (missing), 422 (malformed), or 415 (unsupported). const format = requireContentType(event, def.formats); - return def.handler(event, { format }); + if (!get) { + return def.handler(event, { format }); + } + + return event.req.text().then((query) => { + setQueryContentLocation(event, get, query, format, defaultFormat); + return def.handler(event, { format, query }); + }); }, }); } diff --git a/test/query.test.ts b/test/query.test.ts index f12b2f7a4..b1317b0fa 100644 --- a/test/query.test.ts +++ b/test/query.test.ts @@ -290,6 +290,160 @@ describeMatrix("query utils", (t, { it, expect, describe }) => { expect(res.headers.get("x-middleware")).toBe("1"); }); + describe("get equivalence", () => { + const searchHandler = () => + defineQueryHandler({ + formats: ["application/sql", "application/jsonpath"], + get: "q", + handler: (event, { format, query }) => ({ method: event.req.method, format, query }), + }); + + it("advertises the equivalent GET via Content-Location and serves it identically", async () => { + const h = searchHandler(); + t.app.get("/books", h).query("/books", h); + const queryRes = await t.fetch("/books", { + method: "QUERY", + body: "SELECT * FROM books WHERE author = 'a & b + c'", + headers: { "content-type": "application/sql" }, + }); + expect(queryRes.status).toBe(200); + const location = queryRes.headers.get("content-location")!; + expect(location).toBe( + "/books?q=SELECT+*+FROM+books+WHERE+author+%3D+%27a+%26+b+%2B+c%27&format=application%2Fsql", + ); + const getRes = await t.fetch(location); + expect(getRes.status).toBe(200); + const [queryBody, getBody] = [await queryRes.json(), await getRes.json()]; + expect(getBody).toEqual({ ...queryBody, method: "GET" }); + expect(queryBody.query).toBe("SELECT * FROM books WHERE author = 'a & b + c'"); + }); + + it("preserves existing search params in Content-Location", async () => { + t.app.query("/books", searchHandler()); + const res = await t.fetch("/books?lang=en&q=stale", { + method: "QUERY", + body: "SELECT 1", + headers: { "content-type": "application/sql" }, + }); + const location = new URLSearchParams(res.headers.get("content-location")!.split("?")[1]); + expect(location.get("lang")).toBe("en"); + expect(location.get("q")).toBe("SELECT 1"); + expect(location.get("format")).toBe("application/sql"); + }); + + it("passes the query read from the body on the QUERY path", async () => { + t.app.query("/books", searchHandler()); + const res = await t.fetch("/books", { + method: "QUERY", + body: "$[?(@.year==2015)]", + headers: { "content-type": "application/jsonpath" }, + }); + expect(await res.json()).toEqual({ + method: "QUERY", + format: "application/jsonpath", + query: "$[?(@.year==2015)]", + }); + }); + + it("rejects a GET without the query param with 400", async () => { + t.app.get("/books", searchHandler()); + const res = await t.fetch("/books"); + expect(res.status).toBe(400); + expect(res.headers.get("accept-query")).toBe("application/sql, application/jsonpath"); + }); + + it("rejects a GET with an unsupported format param with 400", async () => { + t.app.get("/books", searchHandler()); + const res = await t.fetch("/books?q=SELECT+1&format=text/plain"); + expect(res.status).toBe(400); + }); + + it("rejects an ambiguous GET (multiple formats, no format param) with 400", async () => { + t.app.get("/books", searchHandler()); + const res = await t.fetch("/books?q=SELECT+1"); + expect(res.status).toBe(400); + }); + + it("defaults the format on GET for a single concrete format and omits it from Content-Location", async () => { + const h = defineQueryHandler({ + formats: ["application/sql"], + get: "q", + handler: (_event, { format, query }) => ({ format, query }), + }); + t.app.get("/books", h).query("/books", h); + const getRes = await t.fetch("/books?q=SELECT+1"); + expect(await getRes.json()).toEqual({ format: "application/sql", query: "SELECT 1" }); + const queryRes = await t.fetch("/books", { + method: "QUERY", + body: "SELECT 1", + headers: { "content-type": "application/sql" }, + }); + expect(queryRes.headers.get("content-location")).toBe("/books?q=SELECT+1"); + }); + + it("requires the format param on GET for a single wildcard format", async () => { + t.app.get( + "/books", + defineQueryHandler({ + formats: ["application/*"], + get: "q", + handler: (_event, { format }) => format, + }), + ); + const missing = await t.fetch("/books?q=x"); + expect(missing.status).toBe(400); + const res = await t.fetch("/books?q=x&format=application/jsonpath"); + expect(await res.text()).toBe("application/jsonpath"); + }); + + it("supports custom param names via the object form", async () => { + t.app.get( + "/books", + defineQueryHandler({ + formats: ["application/sql", "application/jsonpath"], + get: { param: "query", formatParam: "as" }, + handler: (_event, { format, query }) => ({ format, query }), + }), + ); + const res = await t.fetch("/books?query=SELECT+1&as=application/sql"); + expect(await res.json()).toEqual({ format: "application/sql", query: "SELECT 1" }); + }); + + it("skips Content-Location when the equivalent GET URL would be too long", async () => { + t.app.query("/books", searchHandler()); + const res = await t.fetch("/books", { + method: "QUERY", + body: `SELECT ${"x".repeat(3000)}`, + headers: { "content-type": "application/sql" }, + }); + expect(res.status).toBe(200); + expect(res.headers.has("content-location")).toBe(false); + }); + + it("does not set Content-Location on the GET path", async () => { + t.app.get("/books", searchHandler()); + const res = await t.fetch("/books?q=SELECT+1&format=application/sql"); + expect(res.status).toBe(200); + expect(res.headers.has("content-location")).toBe(false); + }); + + it("serves HEAD requests with an empty body", async () => { + t.app.all("/books", searchHandler()); + const res = await t.fetch("/books?q=SELECT+1&format=application/sql", { + method: "HEAD", + }); + expect(res.status).toBe(200); + expect(await res.text()).toBe(""); + }); + + it("rejects other methods with 405 and Allow: GET, HEAD, QUERY", async () => { + t.app.all("/books", searchHandler()); + const res = await t.fetch("/books", { method: "POST", body: "x" }); + expect(res.status).toBe(405); + expect(res.headers.get("allow")).toBe("GET, HEAD, QUERY"); + }); + }); + it("throws at definition time for an empty formats list", () => { expect(() => defineQueryHandler({ formats: [], handler: () => "" })).toThrow(TypeError); }); From f8af678be5a81ee546809b266f6cb089602c2a38 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 10 Jul 2026 07:57:49 +0000 Subject: [PATCH 2/2] feat: serve HEAD via automatic GET route matching Relies on the HEAD->GET route fallback (#1452): with the `get` option, plain .get()/.query() registration now also covers HEAD requests. Co-Authored-By: Claude Fable 5 --- docs/2.utils/1.request.md | 3 ++- docs/4.examples/handle-query.md | 2 +- examples/query.mjs | 4 ++++ src/utils/query.ts | 3 ++- test/query.test.ts | 6 ++++-- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/2.utils/1.request.md b/docs/2.utils/1.request.md index b355fea43..34971b553 100644 --- a/docs/2.utils/1.request.md +++ b/docs/2.utils/1.request.md @@ -146,7 +146,8 @@ search param (and the format from `?format=`), the handler receives the resolved `query` in its context on both paths, and successful QUERY responses advertise the equivalent GET via `Content-Location` — preserving existing search params, and skipped when the URL would exceed 2048 chars. -Register the handler for both methods; GET-path rejections are `400`. +Register the handler for both methods; `HEAD` is served automatically via +the GET route. GET-path rejections are `400`. ``` **Example:** diff --git a/docs/4.examples/handle-query.md b/docs/4.examples/handle-query.md index 43363a796..10cc3731c 100644 --- a/docs/4.examples/handle-query.md +++ b/docs/4.examples/handle-query.md @@ -47,7 +47,7 @@ app.get("/books", searchBooks).query("/books", searchBooks); // GET /books?q=... -> same result, ordinary HTTP caching applies ``` -With `get` set, the handler receives the resolved `query` in its context on both paths (read from the body on `QUERY`, from the URL param on `GET`/`HEAD`). On `GET`, the format comes from `?format=` (customizable via `get: { param, formatParam }`) and may be omitted when exactly one concrete format is accepted; rejections on the `GET` path are `400`. `Content-Location` preserves the request's existing search params and is skipped when the equivalent URL would exceed 2048 characters — very long queries are the reason `QUERY` exists. +With `get` set, the handler receives the resolved `query` in its context on both paths (read from the body on `QUERY`, from the URL param on `GET`/`HEAD`). On `GET`, the format comes from `?format=` (customizable via `get: { param, formatParam }`) and may be omitted when exactly one concrete format is accepted; rejections on the `GET` path are `400`. `Content-Location` preserves the request's existing search params and is skipped when the equivalent URL would exceed 2048 characters — very long queries are the reason `QUERY` exists. `HEAD` requests are served too — h3 [automatically matches `GET` routes for `HEAD`](/guide/basics/routing#head-requests), so the `app.get()` registration covers them. ## Register a `QUERY` Handler diff --git a/examples/query.mjs b/examples/query.mjs index a169f14ee..d25c219df 100644 --- a/examples/query.mjs +++ b/examples/query.mjs @@ -119,6 +119,10 @@ serve(app); // curl -i "http://localhost:3000/books?q=SELECT%20*%20FROM%20books%20WHERE%20author%20=%20'Simpson'&format=application/sql" // # -> 200, Cache-Control: public, max-age=60 // +// # Or probe it with HEAD (served automatically via the GET route): +// curl -I "http://localhost:3000/books?q=SELECT%20*%20FROM%20books%20WHERE%20author%20=%20'Simpson'&format=application/sql" +// # -> 200, same headers, no body +// // # JSONPath query: // curl -X QUERY http://localhost:3000/books \ // -H "Content-Type: application/jsonpath" \ diff --git a/src/utils/query.ts b/src/utils/query.ts index eff078bef..70cf42af8 100644 --- a/src/utils/query.ts +++ b/src/utils/query.ts @@ -164,7 +164,8 @@ export function defineQueryHandler( * resolved `query` in its context on both paths, and successful QUERY * responses advertise the equivalent GET via `Content-Location` — preserving * existing search params, and skipped when the URL would exceed 2048 chars. - * Register the handler for both methods; GET-path rejections are `400`. + * Register the handler for both methods; `HEAD` is served automatically via + * the GET route. GET-path rejections are `400`. * * @example * const searchBooks = defineQueryHandler({ diff --git a/test/query.test.ts b/test/query.test.ts index b1317b0fa..bfef49d44 100644 --- a/test/query.test.ts +++ b/test/query.test.ts @@ -427,12 +427,14 @@ describeMatrix("query utils", (t, { it, expect, describe }) => { expect(res.headers.has("content-location")).toBe(false); }); - it("serves HEAD requests with an empty body", async () => { - t.app.all("/books", searchHandler()); + it("serves HEAD requests via the GET route with an empty body", async () => { + const h = searchHandler(); + t.app.get("/books", h).query("/books", h); const res = await t.fetch("/books?q=SELECT+1&format=application/sql", { method: "HEAD", }); expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toMatch(/application\/json/); expect(await res.text()).toBe(""); });