From 3d8ad753ca62df957a7552dc772f10186a6079dd Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 9 Jul 2026 22:21:34 +0000 Subject: [PATCH 1/4] feat: add `defineQueryHandler` Co-Authored-By: Claude Opus 4.8 --- docs/2.utils/1.request.md | 23 +++++ docs/4.examples/handle-query.md | 23 ++++- examples/query.mjs | 44 +++++----- src/index.ts | 2 +- src/utils/internal/media-type.ts | 99 +++++++++++++++++++++ src/utils/query.ts | 144 +++++++++++++------------------ test/query.test.ts | 143 ++++++++++++++++++++++++++++++ test/unit/package.test.ts | 1 + 8 files changed, 374 insertions(+), 105 deletions(-) create mode 100644 src/utils/internal/media-type.ts diff --git a/docs/2.utils/1.request.md b/docs/2.utils/1.request.md index 5b3abea79..ed2b666c9 100644 --- a/docs/2.utils/1.request.md +++ b/docs/2.utils/1.request.md @@ -122,6 +122,29 @@ app.query("/search", (event) => { }); ``` +### `defineQueryHandler()` + +Define an HTTP `QUERY` method handler (RFC 10008) with the accepted query `formats` enforced and advertised. + +The `formats` array lists the accepted query media types (wildcards like `application/*` are supported). On every response — including error responses — they are advertised via the `Accept-Query` header. The handler receives the matched request media type as `format` (lower-cased, without parameters) and reads the query from the request body as usual. + +Requests are rejected with `405` (non-`QUERY` method), `400` (missing `Content-Type`), `422` (malformed `Content-Type`), or `415` (unsupported query format). + +**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); + }, + }), +); +``` + ### `requireContentType(event, acceptedTypes)` Assert that the request `Content-Type` is present and one of the accepted media types, following the requirements of RFC 10008 for the HTTP `QUERY` method. diff --git a/docs/4.examples/handle-query.md b/docs/4.examples/handle-query.md index 3757df413..67436dabd 100644 --- a/docs/4.examples/handle-query.md +++ b/docs/4.examples/handle-query.md @@ -8,7 +8,28 @@ icon: ph:arrow-right The [HTTP `QUERY` method (RFC 10008)](https://www.rfc-editor.org/rfc/rfc10008) is like `GET` — **safe, idempotent, and cacheable** — but carries a query in the request **body** with a `Content-Type`. It's the standard answer to "I need a GET, but my query is too large or too structured for the URL". -H3 supports `QUERY` as a first-class method via [`app.query()`](/guide/basics/routing#http-query-method), plus two helper utilities. +H3 supports `QUERY` as a first-class method via [`app.query()`](/guide/basics/routing#http-query-method), a high-level [`defineQueryHandler`](#define-a-query-handler) factory, and two lower-level helper utilities. + +## Define a `QUERY` Handler + +[`defineQueryHandler`](/utils/request#definequeryhandlerdef) captures the whole RFC 10008 ceremony: declare the accepted query `formats`, and it advertises them via `Accept-Query` on every response (including errors), validates the request `Content-Type` (`400`/`415`/`422`, plus `405` for non-`QUERY` methods), and passes the matched media type to the handler as `format`: + +```ts +import { defineQueryHandler, readBody } from "h3"; + +app.query( + "/books", + defineQueryHandler({ + formats: ["application/sql", "application/jsonpath"], + handler: async (event, { format }) => { + const query = await readBody(event, { type: "text" }); + return runQuery(format, 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. ## Register a `QUERY` Handler diff --git a/examples/query.mjs b/examples/query.mjs index 51ff5c28b..5a10b11ce 100644 --- a/examples/query.mjs +++ b/examples/query.mjs @@ -4,7 +4,7 @@ import { html, getRouterParam, appendAcceptQuery, - requireContentType, + defineQueryHandler, readBody, HTTPError, } from "../dist/_entries/node.mjs"; @@ -116,28 +116,30 @@ app event.res.headers.set("cache-control", "public, max-age=60"); return result; }) - .query("/books", async (event) => { - // Echo the accepted formats via the `Accept-Query` response header so a - // successful response also advertises what this resource understands. - appendAcceptQuery(event, ACCEPTED); - - // Validate the request `Content-Type`. Throws 400 (missing), - // 422 (malformed), or 415 (unsupported) — and returns the matched type. - const type = requireContentType(event, ACCEPTED); + .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); - const query = (await readBody(event, { type: "text" }))?.trim() ?? ""; - const result = runQuery(type, 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}`); - // 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(type, query); - cache.set(id, result); - event.res.headers.set("content-location", `/books/${id}`); - - return result; - }); + return result; + }, + }), + ); serve(app); diff --git a/src/index.ts b/src/index.ts index 0a39f98ac..80c1b9ca0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -113,7 +113,7 @@ export { // Query (RFC 10008 HTTP QUERY method) -export { appendAcceptQuery, requireContentType } from "./utils/query.ts"; +export { appendAcceptQuery, requireContentType, defineQueryHandler } from "./utils/query.ts"; // Middleware diff --git a/src/utils/internal/media-type.ts b/src/utils/internal/media-type.ts new file mode 100644 index 000000000..1e97a9620 --- /dev/null +++ b/src/utils/internal/media-type.ts @@ -0,0 +1,99 @@ +// Media type helpers shared by the HTTP QUERY method utilities (RFC 10008). + +// sf-token: ( ALPHA / "*" ) *( tchar / ":" / "/" ) — https://www.rfc-editor.org/rfc/rfc8941#section-3.3.4 +const SF_TOKEN_RE = /^[A-Za-z*][\w!#$%&'*+.^`|~:/-]*$/; +// sf-key: ( lcalpha / "*" ) *( lcalpha / DIGIT / "_" / "-" / "." / "*" ) +const SF_KEY_RE = /^[a-z*][a-z0-9_.*-]*$/; + +/** + * Serialize media types into an `Accept-Query` header value: a + * [Structured Fields](https://www.rfc-editor.org/rfc/rfc8941) List where the + * base media type becomes a token and any `;name=value` parameters are + * emitted with their values as quoted strings. + */ +export function serializeAcceptQuery(mediaTypes: string[]): string { + return mediaTypes.map(serializeMediaType).join(", "); +} + +/** Extract the lower-cased `type/subtype` part of a media type, dropping parameters. */ +export function baseMediaType(mediaType: string): string { + return mediaType.split(";")[0].trim().toLowerCase(); +} + +/** Match a concrete `type/subtype` against an accepted type that may use wildcards. */ +export function mediaTypeMatches(mediaType: string, accepted: string): boolean { + if (accepted === "*/*" || accepted === "*") { + return true; + } + if (accepted === mediaType) { + return true; + } + if (accepted.endsWith("/*")) { + return mediaType.startsWith(accepted.slice(0, -1)); + } + return false; +} + +/** Serialize a `type/subtype;param=value` media type into a Structured Fields item. */ +function serializeMediaType(mediaType: string): string { + const parts = splitOutsideQuotes(mediaType, ";"); + const base = parts[0].trim(); + if (!SF_TOKEN_RE.test(base)) { + throw new TypeError(`Invalid media type: ${JSON.stringify(mediaType)}`); + } + let result = base; + for (let i = 1; i < parts.length; i++) { + const param = parts[i].trim(); + if (!param) { + continue; + } + const eq = param.indexOf("="); + const key = (eq === -1 ? param : param.slice(0, eq)).trim().toLowerCase(); + if (!SF_KEY_RE.test(key)) { + throw new TypeError(`Invalid media type parameter: ${JSON.stringify(param)}`); + } + // Bare parameters serialize to the boolean `true` (an implicit `;key`). + result += + eq === -1 ? `;${key}` : `;${key}="${escapeQuotes(unquote(param.slice(eq + 1).trim()))}"`; + } + return result; +} + +/** Split on `sep` while ignoring separators inside double-quoted strings. */ +function splitOutsideQuotes(input: string, sep: string): string[] { + const parts: string[] = []; + let current = ""; + let inQuotes = false; + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + if (inQuotes) { + current += ch; + if (ch === "\\" && i + 1 < input.length) { + current += input[++i]; + } else if (ch === '"') { + inQuotes = false; + } + } else if (ch === '"') { + inQuotes = true; + current += ch; + } else if (ch === sep) { + parts.push(current); + current = ""; + } else { + current += ch; + } + } + parts.push(current); + return parts; +} + +function escapeQuotes(value: string): string { + return value.replace(/[\\"]/g, "\\$&"); +} + +function unquote(value: string): string { + if (value.length >= 2 && value[0] === '"' && value.endsWith('"')) { + return value.slice(1, -1).replace(/\\(.)/g, "$1"); + } + return value; +} diff --git a/src/utils/query.ts b/src/utils/query.ts index 50e952b56..949fac20b 100644 --- a/src/utils/query.ts +++ b/src/utils/query.ts @@ -1,6 +1,9 @@ import { HTTPError } from "../error.ts"; +import { defineHandler } from "../handler.ts"; +import { serializeAcceptQuery, baseMediaType, mediaTypeMatches } from "./internal/media-type.ts"; import type { H3Event, HTTPEvent } from "../event.ts"; +import type { EventHandlerObject, EventHandlerWithFetch } from "../types/handler.ts"; /** * Advertise the query formats a resource accepts by setting the `Accept-Query` @@ -26,10 +29,9 @@ export function appendAcceptQuery(event: H3Event, mediaTypes: string | string[]) if (list.length === 0) { return; } - const value = list.map(serializeMediaType).join(", "); // Append so multiple callers (e.g. middleware + handler) accumulate formats // into a single comma-separated Structured Fields List instead of clobbering. - event.res.headers.append("accept-query", value); + event.res.headers.append("accept-query", serializeAcceptQuery(list)); } /** @@ -69,7 +71,7 @@ export function requireContentType(event: HTTPEvent, acceptedTypes: string | str }); } - const mediaType = header.split(";")[0].trim().toLowerCase(); + const mediaType = baseMediaType(header); const slash = mediaType.indexOf("/"); if (slash <= 0 || slash === mediaType.length - 1) { throw new HTTPError({ @@ -83,9 +85,7 @@ export function requireContentType(event: HTTPEvent, acceptedTypes: string | str // Strip parameters from accepted entries too so a parameterized accepted type // (e.g. "application/json; charset=utf-8") still matches the parameter-less // request media type computed above. - if ( - accepted.some((type) => mediaTypeMatches(mediaType, type.split(";")[0].trim().toLowerCase())) - ) { + if (accepted.some((type) => mediaTypeMatches(mediaType, baseMediaType(type)))) { return mediaType; } @@ -96,86 +96,66 @@ export function requireContentType(event: HTTPEvent, acceptedTypes: string | str }); } -// --- internal helpers --- - -// sf-token: ( ALPHA / "*" ) *( tchar / ":" / "/" ) — https://www.rfc-editor.org/rfc/rfc8941#section-3.3.4 -const SF_TOKEN_RE = /^[A-Za-z*][\w!#$%&'*+.^`|~:/-]*$/; -// sf-key: ( lcalpha / "*" ) *( lcalpha / DIGIT / "_" / "-" / "." / "*" ) -const SF_KEY_RE = /^[a-z*][a-z0-9_.*-]*$/; - -/** Serialize a `type/subtype;param=value` media type into a Structured Fields item. */ -function serializeMediaType(mediaType: string): string { - const parts = splitOutsideQuotes(mediaType, ";"); - const base = parts[0].trim(); - if (!SF_TOKEN_RE.test(base)) { - throw new TypeError(`Invalid media type: ${JSON.stringify(mediaType)}`); - } - let result = base; - for (let i = 1; i < parts.length; i++) { - const param = parts[i].trim(); - if (!param) { - continue; - } - const eq = param.indexOf("="); - const key = (eq === -1 ? param : param.slice(0, eq)).trim().toLowerCase(); - if (!SF_KEY_RE.test(key)) { - throw new TypeError(`Invalid media type parameter: ${JSON.stringify(param)}`); - } - // Bare parameters serialize to the boolean `true` (an implicit `;key`). - result += - eq === -1 ? `;${key}` : `;${key}="${escapeQuotes(unquote(param.slice(eq + 1).trim()))}"`; +/** + * Define an HTTP `QUERY` method handler (RFC 10008) with the accepted query + * `formats` enforced and advertised. + * + * The `formats` array lists the accepted query media types (wildcards like + * `application/*` are supported). On every response — including error + * responses — they are advertised via the `Accept-Query` header. The handler + * receives the matched request media type as `format` (lower-cased, without + * parameters) and reads the query from the request body as usual. + * + * Requests are rejected with `405` (non-`QUERY` method), `400` (missing + * `Content-Type`), `422` (malformed `Content-Type`), or `415` (unsupported + * query format). + * + * @example + * app.query("/books", defineQueryHandler({ + * formats: ["application/sql", "application/jsonpath"], + * handler: async (event, { format }) => { + * const query = await readBody(event, { type: "text" }); + * return runQuery(format, query); + * }, + * })); + * + * @param def Handler options: the accepted `formats`, the `handler`, plus optional `middleware` and `meta`. + */ +export function defineQueryHandler( + def: Omit & { + formats: string[]; + handler: (event: H3Event, context: { format: string }) => unknown | Promise; + }, +): EventHandlerWithFetch { + if (def.formats.length === 0) { + throw new TypeError("defineQueryHandler requires at least one format"); } - return result; -} -function mediaTypeMatches(mediaType: string, accepted: string): boolean { - if (accepted === "*/*" || accepted === "*") { - return true; - } - if (accepted === mediaType) { - return true; - } - if (accepted.endsWith("/*")) { - return mediaType.startsWith(accepted.slice(0, -1)); - } - return false; -} + // Serialize once at definition time: validates the media types eagerly and + // avoids re-serializing on every request. + const acceptQuery = serializeAcceptQuery(def.formats); + + return defineHandler({ + ...def, + handler: function _queryHandler(event) { + // Advertise the accepted formats on every response, including error + // responses (405/415/...), so clients can discover the supported query + // formats per RFC 10008. + event.res.headers.append("accept-query", acceptQuery); + event.res.errHeaders.append("accept-query", acceptQuery); -/** Split on `sep` while ignoring separators inside double-quoted strings. */ -function splitOutsideQuotes(input: string, sep: string): string[] { - const parts: string[] = []; - let current = ""; - let inQuotes = false; - for (let i = 0; i < input.length; i++) { - const ch = input[i]; - if (inQuotes) { - current += ch; - if (ch === "\\" && i + 1 < input.length) { - current += input[++i]; - } else if (ch === '"') { - inQuotes = false; + if (event.req.method !== "QUERY") { + throw new HTTPError({ + status: 405, + statusText: "Method Not Allowed", + headers: { allow: "QUERY" }, + }); } - } else if (ch === '"') { - inQuotes = true; - current += ch; - } else if (ch === sep) { - parts.push(current); - current = ""; - } else { - current += ch; - } - } - parts.push(current); - return parts; -} -function escapeQuotes(value: string): string { - return value.replace(/[\\"]/g, "\\$&"); -} + // Throws 400 (missing), 422 (malformed), or 415 (unsupported). + const format = requireContentType(event, def.formats); -function unquote(value: string): string { - if (value.length >= 2 && value[0] === '"' && value.endsWith('"')) { - return value.slice(1, -1).replace(/\\(.)/g, "$1"); - } - return value; + return def.handler(event, { format }); + }, + }); } diff --git a/test/query.test.ts b/test/query.test.ts index 7ec8540e0..f12b2f7a4 100644 --- a/test/query.test.ts +++ b/test/query.test.ts @@ -1,5 +1,6 @@ import { appendAcceptQuery, + defineQueryHandler, handleCacheHeaders, handleCors, requireContentType, @@ -158,6 +159,148 @@ describeMatrix("query utils", (t, { it, expect, describe }) => { }); }); + describe("defineQueryHandler", () => { + const booksHandler = () => + defineQueryHandler({ + formats: ["application/sql", "application/jsonpath"], + handler: async (event, { format }) => ({ format, query: await event.req.text() }), + }); + + it("passes the matched format and lets the handler read the query body", async () => { + t.app.query("/books", booksHandler()); + const res = await t.fetch("/books", { + method: "QUERY", + body: "$[?(@.year==2015)]", + headers: { "content-type": "application/jsonpath" }, + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + format: "application/jsonpath", + query: "$[?(@.year==2015)]", + }); + }); + + it("matches a Content-Type that carries parameters", async () => { + t.app.query("/books", booksHandler()); + const res = await t.fetch("/books", { + method: "QUERY", + body: "SELECT 1", + headers: { "content-type": "application/sql; charset=utf-8" }, + }); + expect(await res.json()).toEqual({ format: "application/sql", query: "SELECT 1" }); + }); + + it("supports wildcard formats and reports the concrete request format", async () => { + t.app.query( + "/books", + defineQueryHandler({ + formats: ["application/*"], + handler: (_event, { format }) => format, + }), + ); + const res = await t.fetch("/books", { + method: "QUERY", + body: "$", + headers: { "content-type": "application/jsonpath" }, + }); + expect(await res.text()).toBe("application/jsonpath"); + }); + + it("advertises Accept-Query on success responses", async () => { + t.app.query("/books", booksHandler()); + const res = await t.fetch("/books", { + method: "QUERY", + body: "SELECT 1", + headers: { "content-type": "application/sql" }, + }); + expect(res.headers.get("accept-query")).toBe("application/sql, application/jsonpath"); + }); + + it("rejects an unsupported format with 415 and still advertises Accept-Query", async () => { + t.app.query("/books", booksHandler()); + const res = await t.fetch("/books", { + method: "QUERY", + body: "{}", + headers: { "content-type": "application/json" }, + }); + expect(res.status).toBe(415); + expect(res.headers.get("accept-query")).toBe("application/sql, application/jsonpath"); + }); + + it("rejects a missing Content-Type with 400", async () => { + t.app.query("/books", booksHandler()); + const res = await t.fetch("/books", { method: "QUERY" }); + expect(res.status).toBe(400); + }); + + it("rejects a malformed Content-Type with 422", async () => { + t.app.query("/books", booksHandler()); + const res = await t.fetch("/books", { + method: "QUERY", + body: "x", + headers: { "content-type": "nonsense" }, + }); + expect(res.status).toBe(422); + }); + + it("rejects non-QUERY methods with 405, Allow and Accept-Query", async () => { + t.app.all("/books", booksHandler()); + const res = await t.fetch("/books"); + expect(res.status).toBe(405); + expect(res.headers.get("allow")).toBe("QUERY"); + expect(res.headers.get("accept-query")).toBe("application/sql, application/jsonpath"); + }); + + it("matches a format that carries parameters", async () => { + t.app.query( + "/books", + defineQueryHandler({ + formats: ["application/SQL; charset=UTF-8"], + handler: (_event, { format }) => format, + }), + ); + const res = await t.fetch("/books", { + method: "QUERY", + body: "SELECT 1", + headers: { "content-type": "application/sql" }, + }); + expect(await res.text()).toBe("application/sql"); + expect(res.headers.get("accept-query")).toBe('application/SQL;charset="UTF-8"'); + }); + + it("runs the middleware option before the handler", async () => { + t.app.query( + "/books", + defineQueryHandler({ + middleware: [ + (event, next) => { + event.res.headers.set("x-middleware", "1"); + return next(); + }, + ], + formats: ["application/sql"], + handler: () => "ok", + }), + ); + const res = await t.fetch("/books", { + method: "QUERY", + body: "SELECT 1", + headers: { "content-type": "application/sql" }, + }); + expect(res.headers.get("x-middleware")).toBe("1"); + }); + + it("throws at definition time for an empty formats list", () => { + expect(() => defineQueryHandler({ formats: [], handler: () => "" })).toThrow(TypeError); + }); + + it("throws at definition time for an invalid media type", () => { + expect(() => defineQueryHandler({ formats: ["not a token"], handler: () => "" })).toThrow( + TypeError, + ); + }); + }); + // QUERY is not a CORS-safelisted method, so browsers preflight it. // h3's CORS is method-agnostic, so no special handling is needed — these // tests guard that a QUERY preflight keeps working like any other method. diff --git a/test/unit/package.test.ts b/test/unit/package.test.ts index 1555b6b33..08529640f 100644 --- a/test/unit/package.test.ts +++ b/test/unit/package.test.ts @@ -40,6 +40,7 @@ describe("h3 package", () => { "defineNodeListener", "defineNodeMiddleware", "definePlugin", + "defineQueryHandler", "defineRoute", "defineValidatedHandler", "defineWebSocket", From 5deb613fd1d5f6611ed54b8351c760b4cbb589aa Mon Sep 17 00:00:00 2001 From: pi0x Date: Fri, 10 Jul 2026 10:08:01 +0200 Subject: [PATCH 2/4] feat: `get` option for `defineQueryHandler` (cacheable GET equivalence) (#1449) --- docs/2.utils/1.request.md | 38 ++++++-- docs/4.examples/handle-query.md | 32 ++++--- examples/query.mjs | 101 +++++++-------------- src/index.ts | 7 +- src/utils/internal/query-get.ts | 87 ++++++++++++++++++ src/utils/query.ts | 96 ++++++++++++++++++-- test/query.test.ts | 156 ++++++++++++++++++++++++++++++++ 7 files changed, 417 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..34971b553 100644 --- a/docs/2.utils/1.request.md +++ b/docs/2.utils/1.request.md @@ -133,16 +133,34 @@ 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; `HEAD` is served automatically via +the GET route. 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..10cc3731c 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. `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 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..d25c219df 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,68 +74,55 @@ 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 // +// # 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/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..70cf42af8 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,31 @@ 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; `HEAD` is served automatically via + * the GET route. 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 +193,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 +213,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..bfef49d44 100644 --- a/test/query.test.ts +++ b/test/query.test.ts @@ -290,6 +290,162 @@ 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 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(""); + }); + + 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 1ffaec591d9ce7ce37e1f113494f3000613ad331 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 10 Jul 2026 13:15:29 +0000 Subject: [PATCH 3/4] up --- docs/2.utils/1.request.md | 6 +++--- docs/4.examples/handle-query.md | 7 ++++--- examples/query.mjs | 10 ++++++---- src/utils/query.ts | 6 +++--- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/docs/2.utils/1.request.md b/docs/2.utils/1.request.md index 34971b553..03dde9527 100644 --- a/docs/2.utils/1.request.md +++ b/docs/2.utils/1.request.md @@ -146,8 +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; `HEAD` is served automatically via -the GET route. GET-path rejections are `400`. +The handler gates the method itself (`405` for anything else), so a single +`app.all` route serves QUERY, GET, and HEAD. GET-path rejections are `400`. ``` **Example:** @@ -158,7 +158,7 @@ const searchBooks = defineQueryHandler({ get: "q", handler: (event, { format, query }) => runQuery(format, query), }); -app.get("/books", searchBooks).query("/books", searchBooks); +app.all("/books", searchBooks); // QUERY /books -> 200 + Content-Location: /books?q=&format= // GET /books?q=... -> same result, ordinary HTTP caching applies ``` diff --git a/docs/4.examples/handle-query.md b/docs/4.examples/handle-query.md index 10cc3731c..d77b15d40 100644 --- a/docs/4.examples/handle-query.md +++ b/docs/4.examples/handle-query.md @@ -33,7 +33,7 @@ Formats may use wildcards (`application/*`, `*/*`) — `format` is always the co ## 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: +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` — the _same_ handler serves the advertised `GET`, so no server-side result store is needed: ```ts const searchBooks = defineQueryHandler({ @@ -42,12 +42,13 @@ const searchBooks = defineQueryHandler({ handler: (event, { format, query }) => runQuery(format, query), }); -app.get("/books", searchBooks).query("/books", searchBooks); +// The handler gates the method itself, so one `all` route serves QUERY/GET/HEAD. +app.all("/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. `HEAD` requests are served too — h3 [automatically matches `GET` routes for `HEAD`](/guide/basics/routing#head-requests), so the `app.get()` registration covers them. +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` is served as the bodiless form of the cacheable `GET` ([RFC 9110 §9.3.2](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.2) — there is no HEAD-of-`QUERY`), so it works only when `get` is set. Registering with `app.all` covers all three and returns `405 Method Not Allowed` (with an `Allow` header) for any other method — the handler enforces the allowed verbs itself, so you don't wire up per-method routes. ## Register a `QUERY` Handler diff --git a/examples/query.mjs b/examples/query.mjs index d25c219df..20932837e 100644 --- a/examples/query.mjs +++ b/examples/query.mjs @@ -97,10 +97,12 @@ const searchBooks = defineQueryHandler({ export const app = new H3(); -app +// `defineQueryHandler` gates the method itself — it serves QUERY (plus GET/HEAD +// when `get` is set) and returns `405 Method Not Allowed` for anything else — so +// a single `all` route is enough; no need to register GET and QUERY separately. +app // .get("/", () => page) - .get("/books", searchBooks) - .query("/books", searchBooks); + .all("/books", searchBooks); serve(app); @@ -119,7 +121,7 @@ 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): +// # Or probe it with HEAD (the bodiless form of the cacheable GET, RFC 9110): // curl -I "http://localhost:3000/books?q=SELECT%20*%20FROM%20books%20WHERE%20author%20=%20'Simpson'&format=application/sql" // # -> 200, same headers, no body // diff --git a/src/utils/query.ts b/src/utils/query.ts index 70cf42af8..262ee6437 100644 --- a/src/utils/query.ts +++ b/src/utils/query.ts @@ -164,8 +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; `HEAD` is served automatically via - * the GET route. GET-path rejections are `400`. + * The handler gates the method itself (`405` for anything else), so a single + * `app.all` route serves QUERY, GET, and HEAD. GET-path rejections are `400`. * * @example * const searchBooks = defineQueryHandler({ @@ -173,7 +173,7 @@ export function defineQueryHandler( * get: "q", * handler: (event, { format, query }) => runQuery(format, query), * }); - * app.get("/books", searchBooks).query("/books", searchBooks); + * app.all("/books", searchBooks); * // QUERY /books -> 200 + Content-Location: /books?q=&format= * // GET /books?q=... -> same result, ordinary HTTP caching applies * From 215a80ed22a70cb6e4d3b2cef1c4e840bc3eca04 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 10 Jul 2026 13:37:05 +0000 Subject: [PATCH 4/4] feat: always read query body as text with `body: false` opt-out Unify `defineQueryHandler` so the body is read as text by default and provided as `context.query` on both the QUERY and GET paths (single `{ format, query }` context). Add `body: false` to opt out and read the body manually (`{ format }` context). Also support the `get: true` shortcut and switch the GET-equivalence defaults to `?q=` (query) and `?f=` (format) param names. Co-Authored-By: Claude Opus 4.8 --- docs/2.utils/1.request.md | 34 ++++++------- docs/4.examples/handle-query.md | 17 +++---- examples/query.mjs | 15 +++--- src/utils/query.ts | 85 ++++++++++++++++++++------------- test/query.test.ts | 36 ++++++++++---- 5 files changed, 109 insertions(+), 78 deletions(-) diff --git a/docs/2.utils/1.request.md b/docs/2.utils/1.request.md index 03dde9527..e7fac8178 100644 --- a/docs/2.utils/1.request.md +++ b/docs/2.utils/1.request.md @@ -126,28 +126,24 @@ app.query("/search", (event) => { Define an HTTP `QUERY` method handler (RFC 10008) with the accepted query `formats` enforced and advertised. -The `formats` array lists the accepted query media types (wildcards like `application/*` are supported). On every response — including error responses — they are advertised via the `Accept-Query` header. The handler receives the matched request media type as `format` (lower-cased, without parameters) and reads the query from the request body as usual. +The `formats` array lists the accepted query media types (wildcards like `application/*` are supported). On every response — including error responses — they are advertised via the `Accept-Query` header. The handler receives the matched request media type as `format` (lower-cased, without parameters) and the query text as `query`, read from the request body. Requests are rejected with `405` (non-`QUERY` method), `400` (missing `Content-Type`), `422` (malformed `Content-Type`), or `415` (unsupported query format). +Set `body: false` to opt out of reading the request body: the handler then receives only `{ format }` and reads the body itself (e.g. as a stream or with a custom parser). + +With the `get` option, the same handler also serves an equivalent, HTTP-cacheable GET (RFC 10008 §2.3): the query is read from the `?q=` URL search param and the format from `?f=` (both names configurable), the handler receives the resolved `query` 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. The handler gates the method itself (`405` for anything else), so a single `app.all` route serves QUERY, GET, and HEAD. GET-path rejections are `400`. Pass `get: true` for the default param names, a string to set the query param, or an object to set both. + **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); - }, -})); -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. -The handler gates the method itself (`405` for anything else), so a single -`app.all` route serves QUERY, GET, and HEAD. GET-path rejections are `400`. +app.query( + "/books", + defineQueryHandler({ + formats: ["application/sql", "application/jsonpath"], + handler: (event, { format, query }) => runQuery(format, query), + }), +); ``` **Example:** @@ -155,12 +151,12 @@ The handler gates the method itself (`405` for anything else), so a single ```ts const searchBooks = defineQueryHandler({ formats: ["application/sql", "application/jsonpath"], - get: "q", + get: true, handler: (event, { format, query }) => runQuery(format, query), }); app.all("/books", searchBooks); -// QUERY /books -> 200 + Content-Location: /books?q=&format= -// GET /books?q=... -> same result, ordinary HTTP caching applies +// QUERY /books -> 200 + Content-Location: /books?q=&f= +// 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 d77b15d40..34be7af01 100644 --- a/docs/4.examples/handle-query.md +++ b/docs/4.examples/handle-query.md @@ -12,24 +12,21 @@ H3 supports `QUERY` as a first-class method via [`app.query()`](/guide/basics/ro ## Define a `QUERY` Handler -[`defineQueryHandler`](/utils/request#definequeryhandlerdef) captures the whole RFC 10008 ceremony: declare the accepted query `formats`, and it advertises them via `Accept-Query` on every response (including errors), validates the request `Content-Type` (`400`/`415`/`422`, plus `405` for non-`QUERY` methods), and passes the matched media type to the handler as `format`: +[`defineQueryHandler`](/utils/request#definequeryhandlerdef) captures the whole RFC 10008 ceremony: declare the accepted query `formats`, and it advertises them via `Accept-Query` on every response (including errors), validates the request `Content-Type` (`400`/`415`/`422`, plus `405` for non-`QUERY` methods), reads the body as text, and passes the matched media type and query to the handler as `format` and `query`: ```ts -import { defineQueryHandler, readBody } from "h3"; +import { defineQueryHandler } from "h3"; app.query( "/books", defineQueryHandler({ formats: ["application/sql", "application/jsonpath"], - handler: async (event, { format }) => { - const query = await readBody(event, { type: "text" }); - return runQuery(format, query); - }, + handler: (event, { format, query }) => runQuery(format, 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. +Formats may use wildcards (`application/*`, `*/*`) — `format` is always the concrete request media type. Pass `body: false` to read the body yourself (e.g. as a stream or with a custom parser); the handler then receives only `{ format }`. The sections below show the lower-level utilities it builds on, for when you need custom behavior. ## Offer a Cacheable `GET` Equivalent @@ -38,17 +35,17 @@ A `QUERY` response is not URL-addressable (and content-keyed `QUERY` caching is ```ts const searchBooks = defineQueryHandler({ formats: ["application/sql", "application/jsonpath"], - get: "q", + get: true, handler: (event, { format, query }) => runQuery(format, query), }); // The handler gates the method itself, so one `all` route serves QUERY/GET/HEAD. app.all("/books", searchBooks); -// QUERY /books -> 200 + Content-Location: /books?q=&format= +// QUERY /books -> 200 + Content-Location: /books?q=&f= // 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. `HEAD` is served as the bodiless form of the cacheable `GET` ([RFC 9110 §9.3.2](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.2) — there is no HEAD-of-`QUERY`), so it works only when `get` is set. Registering with `app.all` covers all three and returns `405 Method Not Allowed` (with an `Allow` header) for any other method — the handler enforces the allowed verbs itself, so you don't wire up per-method routes. +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`). `get: true` uses the default `?q=` / `?f=` param names; pass a string to set the query param (`get: "q"`) or an object to set both (`get: { param, formatParam }`). On `GET`, the format comes from `?f=` 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` is served as the bodiless form of the cacheable `GET` ([RFC 9110 §9.3.2](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.2) — there is no HEAD-of-`QUERY`), so it works only when `get` is set. Registering with `app.all` covers all three and returns `405 Method Not Allowed` (with an `Allow` header) for any other method — the handler enforces the allowed verbs itself, so you don't wire up per-method routes. ## Register a `QUERY` Handler diff --git a/examples/query.mjs b/examples/query.mjs index 20932837e..381e032cb 100644 --- a/examples/query.mjs +++ b/examples/query.mjs @@ -80,12 +80,13 @@ const page = html` // 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. +// GET (`/books?q=&f=`): 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. `get: true` uses the default +// `?q=` / `?f=` param names (pass a string or object to customize). const searchBooks = defineQueryHandler({ formats: ACCEPTED, - get: "q", + get: true, handler: (event, { format, query }) => { if (event.req.method === "GET") { // Unlike a QUERY response, this GET is safe for browsers/CDNs to cache. @@ -115,14 +116,14 @@ serve(app); // curl -i -X QUERY http://localhost:3000/books \ // -H "Content-Type: application/sql" \ // --data "SELECT * FROM books WHERE author = 'Simpson'" -// # -> 200, Content-Location: /books?q=SELECT+...&format=application%2Fsql +// # -> 200, Content-Location: /books?q=SELECT+...&f=application%2Fsql // // # 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" +// curl -i "http://localhost:3000/books?q=SELECT%20*%20FROM%20books%20WHERE%20author%20=%20'Simpson'&f=application/sql" // # -> 200, Cache-Control: public, max-age=60 // // # Or probe it with HEAD (the bodiless form of the cacheable GET, RFC 9110): -// curl -I "http://localhost:3000/books?q=SELECT%20*%20FROM%20books%20WHERE%20author%20=%20'Simpson'&format=application/sql" +// curl -I "http://localhost:3000/books?q=SELECT%20*%20FROM%20books%20WHERE%20author%20=%20'Simpson'&f=application/sql" // # -> 200, same headers, no body // // # JSONPath query: diff --git a/src/utils/query.ts b/src/utils/query.ts index 262ee6437..ae8c1f4a0 100644 --- a/src/utils/query.ts +++ b/src/utils/query.ts @@ -102,14 +102,14 @@ export function requireContentType(event: HTTPEvent, acceptedTypes: string | str */ export interface QueryHandlerGetOptions { /** - * URL search param carrying the query on GET/HEAD requests. + * URL search param carrying the query on GET/HEAD requests (default: `"q"`). */ - param: string; + 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. + * (default: `"f"`). It may be omitted by clients when exactly one concrete + * (non-wildcard) format is accepted. */ formatParam?: string; } @@ -120,18 +120,20 @@ type QueryHandlerBase = Omit & { export function defineQueryHandler( def: QueryHandlerBase & { - get: string | QueryHandlerGetOptions; - handler: ( - event: H3Event, - context: { format: string; query: string }, - ) => unknown | Promise; + get?: undefined; + body: false; + handler: (event: H3Event, context: { format: string }) => unknown | Promise; }, ): EventHandlerWithFetch; export function defineQueryHandler( def: QueryHandlerBase & { - get?: undefined; - handler: (event: H3Event, context: { format: string }) => unknown | Promise; + get?: true | string | QueryHandlerGetOptions; + body?: true; + handler: ( + event: H3Event, + context: { format: string; query: string }, + ) => unknown | Promise; }, ): EventHandlerWithFetch; @@ -143,45 +145,49 @@ export function defineQueryHandler( * `application/*` are supported). On every response — including error * responses — they are advertised via the `Accept-Query` header. The handler * receives the matched request media type as `format` (lower-cased, without - * parameters) and reads the query from the request body as usual. + * parameters) and the query text as `query`, read from the request body. * * Requests are rejected with `405` (non-`QUERY` method), `400` (missing * `Content-Type`), `422` (malformed `Content-Type`), or `415` (unsupported * query format). * + * Set `body: false` to opt out of reading the request body: the handler then + * receives only `{ format }` and reads the body itself (e.g. as a stream or + * with a custom parser). + * + * With the `get` option, the same handler also serves an equivalent, + * HTTP-cacheable GET (RFC 10008 §2.3): the query is read from the `?q=` URL + * search param and the format from `?f=` (both names configurable), the handler + * receives the resolved `query` 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. The handler + * gates the method itself (`405` for anything else), so a single `app.all` + * route serves QUERY, GET, and HEAD. GET-path rejections are `400`. Pass + * `get: true` for the default param names, a string to set the query param, or + * an object to set both. + * * @example * app.query("/books", defineQueryHandler({ * formats: ["application/sql", "application/jsonpath"], - * handler: async (event, { format }) => { - * const query = await readBody(event, { type: "text" }); - * return runQuery(format, query); - * }, + * handler: (event, { format, query }) => 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. - * The handler gates the method itself (`405` for anything else), so a single - * `app.all` route serves QUERY, GET, and HEAD. GET-path rejections are `400`. - * * @example * const searchBooks = defineQueryHandler({ * formats: ["application/sql", "application/jsonpath"], - * get: "q", + * get: true, * handler: (event, { format, query }) => runQuery(format, query), * }); * app.all("/books", searchBooks); - * // QUERY /books -> 200 + Content-Location: /books?q=&format= - * // GET /books?q=... -> same result, ordinary HTTP caching applies + * // QUERY /books -> 200 + Content-Location: /books?q=&f= + * // 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`. + * @param def Handler options: the accepted `formats`, the `handler`, optional `get` equivalence, optional `body: false` opt-out, plus optional `middleware` and `meta`. */ export function defineQueryHandler( def: QueryHandlerBase & { - get?: string | QueryHandlerGetOptions; + get?: true | string | QueryHandlerGetOptions; + body?: boolean; handler: (event: H3Event, context: any) => unknown | Promise; }, ): EventHandlerWithFetch { @@ -193,8 +199,14 @@ export function defineQueryHandler( // avoids re-serializing on every request. const acceptQuery = serializeAcceptQuery(def.formats); + const getOpts = + typeof def.get === "object" + ? def.get + : typeof def.get === "string" + ? { param: def.get } + : undefined; const get = def.get - ? { formatParam: "format", ...(typeof def.get === "string" ? { param: def.get } : def.get) } + ? { param: getOpts?.param ?? "q", formatParam: getOpts?.formatParam ?? "f" } : undefined; // With a single concrete (non-wildcard) accepted format, GET requests may @@ -204,6 +216,11 @@ export function defineQueryHandler( ? baseMediaType(def.formats[0]) : undefined; + // Read the query body as text by default, exposing it as `context.query`. + // `get` always reads it (needed for the `query` context and `Content-Location`); + // `body: false` opts out so the handler reads the body itself. + const readBody = !!get || def.body !== false; + return defineHandler({ ...def, handler: function _queryHandler(event) { @@ -230,12 +247,14 @@ export function defineQueryHandler( // Throws 400 (missing), 422 (malformed), or 415 (unsupported). const format = requireContentType(event, def.formats); - if (!get) { + if (!readBody) { return def.handler(event, { format }); } return event.req.text().then((query) => { - setQueryContentLocation(event, get, query, format, defaultFormat); + if (get) { + 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 bfef49d44..d0aa25e82 100644 --- a/test/query.test.ts +++ b/test/query.test.ts @@ -163,10 +163,10 @@ describeMatrix("query utils", (t, { it, expect, describe }) => { const booksHandler = () => defineQueryHandler({ formats: ["application/sql", "application/jsonpath"], - handler: async (event, { format }) => ({ format, query: await event.req.text() }), + handler: (_event, { format, query }) => ({ format, query }), }); - it("passes the matched format and lets the handler read the query body", async () => { + it("passes the matched format and the query body read as text", async () => { t.app.query("/books", booksHandler()); const res = await t.fetch("/books", { method: "QUERY", @@ -290,11 +290,29 @@ describeMatrix("query utils", (t, { it, expect, describe }) => { expect(res.headers.get("x-middleware")).toBe("1"); }); + it("lets the handler read the body itself with `body: false`", async () => { + t.app.query( + "/books", + defineQueryHandler({ + formats: ["application/sql"], + body: false, + handler: async (event, { format }) => ({ format, query: await event.req.text() }), + }), + ); + const res = await t.fetch("/books", { + method: "QUERY", + body: "SELECT 1", + headers: { "content-type": "application/sql" }, + }); + expect(await res.json()).toEqual({ format: "application/sql", query: "SELECT 1" }); + }); + describe("get equivalence", () => { + // `get: true` uses the default `?q=` / `?f=` param names. const searchHandler = () => defineQueryHandler({ formats: ["application/sql", "application/jsonpath"], - get: "q", + get: true, handler: (event, { format, query }) => ({ method: event.req.method, format, query }), }); @@ -309,7 +327,7 @@ describeMatrix("query utils", (t, { it, expect, describe }) => { 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", + "/books?q=SELECT+*+FROM+books+WHERE+author+%3D+%27a+%26+b+%2B+c%27&f=application%2Fsql", ); const getRes = await t.fetch(location); expect(getRes.status).toBe(200); @@ -328,7 +346,7 @@ describeMatrix("query utils", (t, { it, expect, describe }) => { 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"); + expect(location.get("f")).toBe("application/sql"); }); it("passes the query read from the body on the QUERY path", async () => { @@ -354,7 +372,7 @@ describeMatrix("query utils", (t, { it, expect, describe }) => { 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"); + const res = await t.fetch("/books?q=SELECT+1&f=text/plain"); expect(res.status).toBe(400); }); @@ -392,7 +410,7 @@ describeMatrix("query utils", (t, { it, expect, describe }) => { ); const missing = await t.fetch("/books?q=x"); expect(missing.status).toBe(400); - const res = await t.fetch("/books?q=x&format=application/jsonpath"); + const res = await t.fetch("/books?q=x&f=application/jsonpath"); expect(await res.text()).toBe("application/jsonpath"); }); @@ -422,7 +440,7 @@ describeMatrix("query utils", (t, { it, expect, describe }) => { 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"); + const res = await t.fetch("/books?q=SELECT+1&f=application/sql"); expect(res.status).toBe(200); expect(res.headers.has("content-location")).toBe(false); }); @@ -430,7 +448,7 @@ describeMatrix("query utils", (t, { it, expect, describe }) => { 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", { + const res = await t.fetch("/books?q=SELECT+1&f=application/sql", { method: "HEAD", }); expect(res.status).toBe(200);