Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/2.utils/1.request.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<query>&f=<format>
// 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.
Expand Down
53 changes: 38 additions & 15 deletions docs/4.examples/handle-query.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<query>&f=<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`). `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

Expand Down Expand Up @@ -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 `/`.
Expand Down
104 changes: 37 additions & 67 deletions examples/query.mjs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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") {
Expand All @@ -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`<!doctype html>
<title>H3 QUERY Demo</title>
Expand Down Expand Up @@ -96,66 +74,58 @@ const page = html`<!doctype html>
});
</script>`;

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=<query>&f=<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: 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");
}
Comment thread
pi0 marked this conversation as resolved.
// 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/<id>
// # -> 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/<id>
// # 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" \
Expand Down
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
99 changes: 99 additions & 0 deletions src/utils/internal/media-type.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading