Skip to content
Merged
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
38 changes: 28 additions & 10 deletions docs/2.utils/1.request.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<query>&format=<format>
// GET /books?q=... -> same result, ordinary HTTP caching applies
```

### `requireContentType(event, acceptedTypes)`
Expand Down
32 changes: 18 additions & 14 deletions docs/4.examples/handle-query.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<query>&format=<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`:
Expand Down Expand Up @@ -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 `/`.
Expand Down
101 changes: 33 additions & 68 deletions examples/query.mjs
Original file line number Diff line number Diff line change
@@ -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
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,68 +74,55 @@ const page = html`<!doctype html>
});
</script>`;

// `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>&format=<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/<id>
// # -> 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/<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'&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" \
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, defineQueryHandler } from "./utils/query.ts";
export {
appendAcceptQuery,
requireContentType,
defineQueryHandler,
type QueryHandlerGetOptions,
} from "./utils/query.ts";

// Middleware

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