diff --git a/docs/2.utils/1.request.md b/docs/2.utils/1.request.md index 5b3abea79..e7fac8178 100644 --- a/docs/2.utils/1.request.md +++ b/docs/2.utils/1.request.md @@ -122,6 +122,43 @@ 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 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: (event, { format, query }) => runQuery(format, query), + }), +); +``` + +**Example:** + +```ts +const searchBooks = defineQueryHandler({ + formats: ["application/sql", "application/jsonpath"], + get: true, + handler: (event, { format, query }) => runQuery(format, query), +}); +app.all("/books", searchBooks); +// QUERY /books -> 200 + Content-Location: /books?q=&f= +// GET /books?q=... -> same result, ordinary HTTP caching applies +``` + ### `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..34be7af01 100644 --- a/docs/4.examples/handle-query.md +++ b/docs/4.examples/handle-query.md @@ -8,7 +8,44 @@ 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), reads the body as text, and passes the matched media type and query to the handler as `format` and `query`: + +```ts +import { defineQueryHandler } from "h3"; + +app.query( + "/books", + defineQueryHandler({ + formats: ["application/sql", "application/jsonpath"], + handler: (event, { format, query }) => runQuery(format, query), + }), +); +``` + +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 + +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({ + formats: ["application/sql", "application/jsonpath"], + 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=&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`). `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 @@ -53,20 +90,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 51ff5c28b..381e032cb 100644 --- a/examples/query.mjs +++ b/examples/query.mjs @@ -1,13 +1,4 @@ -import { - H3, - serve, - html, - getRouterParam, - appendAcceptQuery, - requireContentType, - 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,58 @@ const page = html` }); `; -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" }); +// `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=&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: true, + 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"); } - // 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", 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); - - const query = (await readBody(event, { type: "text" }))?.trim() ?? ""; - const result = runQuery(type, query); + return runQuery(format, query.trim()); + }, +}); - // 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}`); +export const app = new H3(); - return result; - }); +// `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) + .all("/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+...&f=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'&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'&f=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 0a39f98ac..ff96b0852 100644 --- a/src/index.ts +++ b/src/index.ts @@ -113,7 +113,12 @@ export { // Query (RFC 10008 HTTP QUERY method) -export { appendAcceptQuery, requireContentType } from "./utils/query.ts"; +export { + appendAcceptQuery, + requireContentType, + defineQueryHandler, + type QueryHandlerGetOptions, +} 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/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 50e952b56..ae8c1f4a0 100644 --- a/src/utils/query.ts +++ b/src/utils/query.ts @@ -1,6 +1,10 @@ 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"; /** * Advertise the query formats a resource accepts by setting the `Accept-Query` @@ -26,10 +30,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 +72,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 +86,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 +97,166 @@ 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_.*-]*$/; +/** + * Options for `defineQueryHandler`'s GET equivalence. + */ +export interface QueryHandlerGetOptions { + /** + * URL search param carrying the query on GET/HEAD requests (default: `"q"`). + */ + param?: string; -/** 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; + /** + * URL search param selecting the query format on GET/HEAD requests + * (default: `"f"`). It may be omitted by clients when exactly one concrete + * (non-wildcard) format is accepted. + */ + formatParam?: string; } -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)); +type QueryHandlerBase = Omit & { + formats: string[]; +}; + +export function defineQueryHandler( + def: QueryHandlerBase & { + get?: undefined; + body: false; + handler: (event: H3Event, context: { format: string }) => unknown | Promise; + }, +): EventHandlerWithFetch; + +export function defineQueryHandler( + def: QueryHandlerBase & { + get?: true | string | QueryHandlerGetOptions; + body?: true; + handler: ( + event: H3Event, + context: { format: string; query: string }, + ) => unknown | Promise; + }, +): EventHandlerWithFetch; + +/** + * 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 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: (event, { format, query }) => runQuery(format, query), + * })); + * + * @example + * const searchBooks = defineQueryHandler({ + * formats: ["application/sql", "application/jsonpath"], + * get: true, + * handler: (event, { format, query }) => runQuery(format, query), + * }); + * app.all("/books", searchBooks); + * // 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, optional `body: false` opt-out, plus optional `middleware` and `meta`. + */ +export function defineQueryHandler( + def: QueryHandlerBase & { + get?: true | string | QueryHandlerGetOptions; + body?: boolean; + handler: (event: H3Event, context: any) => unknown | Promise; + }, +): EventHandlerWithFetch { + if (def.formats.length === 0) { + throw new TypeError("defineQueryHandler requires at least one format"); } - return false; -} -/** 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; + // Serialize once at definition time: validates the media types eagerly and + // 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 + ? { param: getOpts?.param ?? "q", formatParam: getOpts?.formatParam ?? "f" } + : 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; + + // 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) { + // 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); + + const method = event.req.method; + if (method !== "QUERY" && !(get && (method === "GET" || method === "HEAD"))) { + throw new HTTPError({ + status: 405, + statusText: "Method Not Allowed", + headers: { allow: get ? "GET, HEAD, QUERY" : "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, "\\$&"); -} + if (method !== "QUERY") { + // GET/HEAD equivalent of a QUERY request (RFC 10008 §2.3). + return def.handler(event, resolveGetQuery(event, get!, def.formats, defaultFormat)); + } -function unquote(value: string): string { - if (value.length >= 2 && value[0] === '"' && value.endsWith('"')) { - return value.slice(1, -1).replace(/\\(.)/g, "$1"); - } - return value; + // Throws 400 (missing), 422 (malformed), or 415 (unsupported). + const format = requireContentType(event, def.formats); + + if (!readBody) { + return def.handler(event, { format }); + } + + return event.req.text().then((query) => { + 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 7ec8540e0..d0aa25e82 100644 --- a/test/query.test.ts +++ b/test/query.test.ts @@ -1,5 +1,6 @@ import { appendAcceptQuery, + defineQueryHandler, handleCacheHeaders, handleCors, requireContentType, @@ -158,6 +159,322 @@ describeMatrix("query utils", (t, { it, expect, describe }) => { }); }); + describe("defineQueryHandler", () => { + const booksHandler = () => + defineQueryHandler({ + formats: ["application/sql", "application/jsonpath"], + handler: (_event, { format, query }) => ({ format, query }), + }); + + 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", + 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("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: true, + 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&f=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("f")).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&f=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&f=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&f=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&f=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); + }); + + 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",