Skip to content

feat: add defineQueryHandler with cacheable GET equivalence#1448

Open
pi0x wants to merge 4 commits into
mainfrom
feat/define-query-handler
Open

feat: add defineQueryHandler with cacheable GET equivalence#1448
pi0x wants to merge 4 commits into
mainfrom
feat/define-query-handler

Conversation

@pi0x

@pi0x pi0x commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

This PR adds defineQueryHandler, a handler factory for the HTTP QUERY method (RFC 10008) that captures the ceremony a QUERY handler repeats. building on top of requireContentType and appendAcceptQuery utils (#1446) and first-class QUERY routing (#1445). This PR also supports an optional get option implementing RFC 10008 §2.3's cacheability guidance.

app.query(
  "/books",
  defineQueryHandler({
    formats: ["application/sql", "application/jsonpath"],
    handler: (event, { format, query }) => runQuery(format, query),
  }),
);

The request body is read as text and passed to the handler as query. Pass body: false to read it yourself (e.g. as a stream or with a custom parser) — the handler then receives only { format }.

With get, the same handler also serves an equivalent, HTTP-cacheable GET, and successful QUERY responses advertise it via Content-Location — no server-side result store needed:

// The handler gates the method itself, so one `all` route serves all three:
app.all(
  "/books",
  defineQueryHandler({
    formats: ["application/sql", "application/jsonpath"],
    get: true,
    handler: (event, { format, query }) => runQuery(format, query),
  }),
);

// QUERY /books      -> 200 + Content-Location: /books?q=<query>&f=<format>
// GET  /books?q=... -> same result, ordinary HTTP caching applies
// HEAD /books?q=... -> bodiless GET (RFC 9110): same headers, no content

Behavior

Core

  • formats lists the accepted query media types (wildcards application/* / */* supported). Serialized once at definition time — invalid media types throw a TypeError at startup, not per-request.
  • The body is read as text by default and passed to the handler as query, so the common case is a single { format, query } context on every path. body: false opts out: the handler receives only { format } and reads the request body itself (typed via overloads).
  • Accept-Query is advertised on every response, including error responses (via event.res.errHeaders), so a rejected client learns what it should have sent.
  • RFC error semantics: 405 + Allow for other methods, then 400 (missing Content-Type) / 422 (malformed) / 415 (unsupported) via requireContentType.
  • The handler receives the matched request media type as format (lower-cased, without parameters); middleware / meta pass through defineHandler like other define* factories.

get equivalence (#1449)

  • With get set, the handler context always has query (body text on QUERY, URL param on GET/HEAD). get: true uses the default ?q= (query) / ?f= (format) param names; pass a string to set the query param (get: "q") or an object to set both (get: { param, formatParam }).
  • GET format comes from ?f=; it may be omitted only when exactly one concrete (non-wildcard) format is accepted — silent defaulting across formats would change query semantics. All GET-path rejections are 400 (per RFC 9110, 415/422 apply to request content, which a GET has none of).
  • Content-Location preserves the request's existing search params (a QUERY to /books?lang=en advertises /books?lang=en&q=…), is built via URLSearchParams, is skipped when the URL would exceed 2048 chars (long queries being the reason QUERY exists), and is never set on the GET path.
  • The handler enforces its own allowed verbs, so registration is a single app.all("/books", searchBooks) — no per-method wiring. It serves QUERY (and GET/HEAD when get is set) and returns 405 + Allow for anything else. The method guard is 405 + Allow: GET, HEAD, QUERY when get is set (Allow: QUERY otherwise).
  • HEAD here is the bodiless form of the cacheable GET (RFC 9110 §9.3.2: "identical to GET except that the server MUST NOT send content"), not a variant of QUERY — there is no HEAD-of-QUERY. It's handled on the GET path (query read from ?q=) and only when get is set; without get there's no URL-addressable representation, so HEAD gets 405. The all route matches it directly; even a GET-only registration would match it automatically (feat: automatically match GET routes for HEAD requests #1452), letting clients revalidate/probe the advertised URL cheaply.

Design notes

  • Always-text body + body: false: reading the body as text by default unifies the context to { format, query } across the QUERY and GET paths (QUERY bodies are text queries by definition), and removes the earlier asymmetry where only the get path pre-read the body. body: false keeps the escape hatch for streaming/binary/custom parsing; body-size limiting still works via bodyLimit middleware (runs before the handler).
  • get stays opt-in: default-on would be inert under the idiomatic app.query() registration (the router never routes GET to a QUERY-only route) and would push query contents into URLs/logs/history for everyone — the opposite of what QUERY's body-carried queries are for. Explicit get signals intent to also mount broadly (app.all).
  • A companion cache: {...} option (wiring handleCacheHeaders) was dropped: it duplicates a documented one-liner, and handleCacheHeaders currently force-prepends public to Cache-Control and ORs If-None-Match/If-Modified-Since (RFC 9110 §13.2.2 wants ETag to take precedence) — worth fixing upstream before building sugar on it. Can open a separate issue.

Changes

  • src/utils/query.ts: defineQueryHandler with get option, body: false opt-out + overloads
  • src/utils/internal/media-type.ts: Structured-Fields/media-type helpers moved out of utils/query.ts; src/utils/internal/query-get.ts: GET resolution + Content-Location helpers (keeps query.ts under the size guideline)
  • test/query.test.ts: matrix tests (dispatch, parameters, wildcards, 400/415/422/405, Accept-Query on errors, body: false, get: true shortcut, middleware, definition-time throws, round-trip identity, param preservation, encoding, length cutoff, HEAD via automatic GET matching)
  • examples/query.mjs: rewritten around the new API — drops the manual result Map, queryId hash, and /books/:id route; registers a single app.all("/books", …) route (the handler gates the verbs); curl comments include a HEAD probe
  • Docs: "Define a QUERY Handler" + "Offer a Cacheable GET Equivalent" sections; fixed a wording error (QUERY responses are cacheable per §2.7, just not URL-addressable)

Test plan

  • Full query.test.ts matrix green (web + node), no type errors, lint clean.

Disclaimer

🤖 This PR was written by Claude Code (design and implementation reviewed and directed by a human).

🤖 Generated with Claude Code

Summary by CodeRabbit

Summary by CodeRabbit

  • New Features

    • Added defineQueryHandler for first-class HTTP QUERY routing with media-type/format negotiation, wildcard support, and consistent Accept-Query on both success and error responses.
    • Added optional cacheable GET/HEAD equivalence via Content-Location, including preservation of existing search params and safe URL-length handling.
    • Supports supplying the handler with the resolved format (and query when enabled), including body: false.
  • Documentation

    • Updated the query handler docs and example to reflect the new QUERY and GET equivalence model.
  • Tests

    • Added extensive tests for validation, headers, method handling, and edge cases.

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds defineQueryHandler for HTTP QUERY requests, shared media-type utilities, equivalent GET/HEAD handling with Content-Location, public exports, updated examples and documentation, and comprehensive tests.

Changes

QUERY handler support

Layer / File(s) Summary
Media-type parsing and matching
src/utils/internal/media-type.ts
Adds media-type serialization, normalization, validation, quoted-parameter parsing, and wildcard matching.
GET equivalence resolution
src/utils/internal/query-get.ts
Resolves query and format parameters for GET/HEAD and conditionally generates equivalent Content-Location URLs.
Query handler construction
src/utils/query.ts, src/index.ts
Adds defineQueryHandler, typed GET options, shared media-type handling, method validation, response headers, and public exports.
Behavior and export validation
test/query.test.ts, test/unit/package.test.ts
Tests QUERY handling, formats, errors, middleware, GET equivalence, HEAD behavior, URL limits, definition validation, and package exports.
Example and documentation integration
examples/query.mjs, docs/2.utils/1.request.md, docs/4.examples/handle-query.md
Updates the example and documentation to use and explain defineQueryHandler and cacheable GET alternatives.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • h3js/h3#1446: Provides the existing query utility behavior reused and extended by this change.

Suggested labels: enhancement

Suggested reviewers: pi0

Poem

I’m a rabbit with queries to send,
Through QUERY and GET, formats now blend.
Headers hop high,
Cache links fly,
And tests guard each burrowed-up end.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main addition: defineQueryHandler and its cacheable GET equivalence behavior.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/define-query-handler

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@pi0x
pi0x force-pushed the feat/define-query-handler branch from 9795969 to 3d8ad75 Compare July 10, 2026 08:06
@pi0
pi0 marked this pull request as ready for review July 10, 2026 08:07
@pi0
pi0 self-requested a review as a code owner July 10, 2026 08:07
@pi0x pi0x changed the title feat: add defineQueryHandler feat: add defineQueryHandler with cacheable GET equivalence Jul 10, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
src/utils/query.ts (1)

100-242: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider splitting defineQueryHandler into its own file.

The file is ~243 lines, exceeding the 200-line guideline. Moving defineQueryHandler and its types (QueryHandlerGetOptions, QueryHandlerBase) to a dedicated file would bring query.ts under the target while improving cohesion.

As per coding guidelines: "Keep files short — aim for less than 200 lines of code per file, split when larger."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/query.ts` around lines 100 - 242, Split defineQueryHandler and its
related types QueryHandlerGetOptions and QueryHandlerBase into a dedicated
module, then update query.ts imports/exports and any callers so behavior and
public API remain unchanged. Keep the supporting query parsing utilities in
query.ts and ensure the extracted module references them through appropriate
imports.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/2.utils/1.request.md`:
- Around line 142-151: Move the HTTP-cacheable GET behavior prose out of the
`@example` block in the source JSDoc for the query handler utility, placing it in
the main description so automd renders it as regular documentation rather than
code. Update the JSDoc in the relevant query utility source, not the generated
markdown, and preserve the example’s actual code content.

In `@docs/4.examples/handle-query.md`:
- Line 15: Update the defineQueryHandler link in the documentation paragraph to
use the generated anchor `/utils/request#definequeryhandler`, replacing the
incorrect `#definequeryhandlerdef` target.

In `@examples/query.mjs`:
- Around line 90-93: Update the cache-control condition in the request handler
to apply when event.req.method is either "GET" or "HEAD", ensuring HEAD
responses receive the same cacheable GET headers.

In `@src/utils/internal/query-get.ts`:
- Around line 78-87: Update setQueryContentLocation() to construct
Content-Location from the client-facing external pathname rather than
event.url.pathname, preserving the mounted base path introduced by
mount()/withBase(). Keep the existing query parameter updates and length guard,
but use the request URL or framework-provided external URL source that retains
the original pathname.

---

Nitpick comments:
In `@src/utils/query.ts`:
- Around line 100-242: Split defineQueryHandler and its related types
QueryHandlerGetOptions and QueryHandlerBase into a dedicated module, then update
query.ts imports/exports and any callers so behavior and public API remain
unchanged. Keep the supporting query parsing utilities in query.ts and ensure
the extracted module references them through appropriate imports.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0105dbe8-1373-420b-a3d3-5eb5cd0dac18

📥 Commits

Reviewing files that changed from the base of the PR and between c495c6c and 5deb613.

📒 Files selected for processing (9)
  • docs/2.utils/1.request.md
  • docs/4.examples/handle-query.md
  • examples/query.mjs
  • src/index.ts
  • src/utils/internal/media-type.ts
  • src/utils/internal/query-get.ts
  • src/utils/query.ts
  • test/query.test.ts
  • test/unit/package.test.ts

Comment thread docs/2.utils/1.request.md Outdated
Comment thread docs/4.examples/handle-query.md Outdated
Comment thread examples/query.mjs
Comment on lines +78 to +87
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);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect how event.url is constructed and whether pathname includes mount/base prefixes.
fd -e ts . src | xargs rg -nP '\burl\b.*=|get url\(|this\.url' -g '!**/*.test.ts' | rg -n 'url' | head -50
ast-grep outline src/event.ts --items all

Repository: h3js/h3

Length of output: 3700


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in src/event.ts src/utils/request.ts src/utils/base.ts src/h3.ts src/utils/internal/query-get.ts; do
  echo "===== $f ====="
  wc -l "$f"
done

echo "===== src/event.ts (60-95) ====="
sed -n '60,95p' src/event.ts

echo "===== src/utils/request.ts (1-60) ====="
sed -n '1,60p' src/utils/request.ts

echo "===== src/utils/base.ts (1-60) ====="
sed -n '1,60p' src/utils/base.ts

echo "===== src/h3.ts (130-155) ====="
sed -n '130,155p' src/h3.ts

echo "===== src/utils/internal/query-get.ts (70-95) ====="
sed -n '70,95p' src/utils/internal/query-get.ts

Repository: h3js/h3

Length of output: 6971


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the exact code paths that manipulate event.url.pathname and the Content-Location builder.
sed -n '60,95p' src/event.ts
sed -n '1,60p' src/utils/request.ts
sed -n '1,60p' src/utils/base.ts
sed -n '130,155p' src/h3.ts
sed -n '70,95p' src/utils/internal/query-get.ts

Repository: h3js/h3

Length of output: 6511


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== src/event.ts (60-95) ====="
sed -n '60,95p' src/event.ts

echo "===== src/utils/request.ts (1-60) ====="
sed -n '1,60p' src/utils/request.ts

echo "===== src/utils/base.ts (1-60) ====="
sed -n '1,60p' src/utils/base.ts

echo "===== src/h3.ts (130-155) ====="
sed -n '130,155p' src/h3.ts

echo "===== src/utils/internal/query-get.ts (70-95) ====="
sed -n '70,95p' src/utils/internal/query-get.ts

Repository: h3js/h3

Length of output: 6705


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== usages of setQueryContentLocation ====="
rg -n "setQueryContentLocation" src

echo "===== src/utils/internal/query-get.ts (1-120) ====="
sed -n '1,120p' src/utils/internal/query-get.ts

echo "===== related query handler code ====="
rg -n "QueryGetOptions|defaultFormat|content-location|query-get|get\.formatParam|get\.param" src

Repository: h3js/h3

Length of output: 4903


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '180,255p' src/utils/query.ts

Repository: h3js/h3

Length of output: 2541


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== tests mentioning defineQueryHandler / Content-Location / mount / withBase ====="
rg -n "defineQueryHandler|content-location|Content-Location|withBase|mount\\(" test src --glob '!**/*.snap'

echo "===== query-related tests ====="
rg -n "defineQueryHandler|QUERY|accept-query|content-location" test src --glob '!**/*.snap'

Repository: h3js/h3

Length of output: 16727


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "defineQueryHandler|content-location|Content-Location|withBase|mount\\(" . --glob '!**/*.snap' --glob '!**/dist/**' --glob '!**/coverage/**'

Repository: h3js/h3

Length of output: 11320


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the docs / comments around QUERY equivalence for clues about the intended URL form.
rg -n "RFC 10008|equivalent GET|Content-Location|mounted|base path|base url" src README.md docs . --glob '!**/*.snap' --glob '!**/dist/**' --glob '!**/coverage/**'

Repository: h3js/h3

Length of output: 15398


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find any tests or examples showing QUERY under a base path.
rg -n "withBase\\(|mount\\(|QUERY" . --glob '!**/*.snap' --glob '!**/dist/**' --glob '!**/coverage/**'

Repository: h3js/h3

Length of output: 17476


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the main handler wrapper to see whether it rewrites the URL around handler execution.
sed -n '1,140p' src/handler.ts

Repository: h3js/h3

Length of output: 4341


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== matches ====="
rg -n "defineQueryHandler|content-location|Content-Location|withBase\\(|mount\\(|RFC 10008|equivalent GET|QUERY" . --glob '!**/*.snap' --glob '!**/dist/**' --glob '!**/coverage/**'

echo "===== src/handler.ts (1-140) ====="
sed -n '1,140p' src/handler.ts

echo "===== src/utils/query.ts (180-255) ====="
sed -n '180,255p' src/utils/query.ts

Repository: h3js/h3

Length of output: 28739


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== src/event.ts (140-170) ====="
sed -n '140,170p' src/event.ts

echo "===== docs nested-app note ====="
sed -n '35,55p' docs/1.guide/1.basics/7.nested-apps.md

Repository: h3js/h3

Length of output: 1493


Content-Location should use the external pathname under mounts
setQueryContentLocation() reads event.url.pathname, but mount()/withBase() rewrite that to the internal path before the handler runs. Under a mounted base, the header will point to /books?... instead of the client-facing /api/books?....

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/internal/query-get.ts` around lines 78 - 87, Update
setQueryContentLocation() to construct Content-Location from the client-facing
external pathname rather than event.url.pathname, preserving the mounted base
path introduced by mount()/withBase(). Keep the existing query parameter updates
and length guard, but use the request URL or framework-provided external URL
source that retains the original pathname.

pi0 and others added 2 commits July 10, 2026 13:15
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 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants