From 4b703570415952af73626fe5cd5dea169071901c Mon Sep 17 00:00:00 2001 From: rejifald Date: Thu, 6 Aug 2026 16:35:44 +0300 Subject: [PATCH] feat(core): `QUERY` is a read, not a write (Refs #462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 1 of #462 only — the keepalive/Beacon half (part 2) is untouched, which is why this is `Refs`, not `Closes`. `method: 'QUERY'` already sent its body and already cached correctly under `cache: { methods: 'QUERY' }`. What it did not have was the engine's agreement that it is a read: "safe method" was spelled `=== 'GET' || === 'HEAD'` inline, so everything else was a write by default. One `isSafeMethod` predicate (RFC 9110 §9.2.1's safe set plus QUERY) now answers that in the two places that ask it — `applyIdempotency`, so a QUERY is no longer stamped with an `Idempotency-Key`, and the construction nudge that mirrors that drop, so it is never silent. OPTIONS/TRACE stop being stamped too; they were only getting a key because they were not GET or HEAD. A 301/302 no longer downgrades a QUERY to a bodyless GET on the default fetch transport. That downgrade is a historical exception granted to POST, and draft-ietf-httpbis-safe-method-w-body rules it out by name for QUERY; applying it dropped the body, silently turning a filtered read into an unfiltered one. The two look-alike method tests are deliberately NOT routed through the predicate, and the comments say why: `encodeRequestBody` still drops a body on GET/HEAD only (a transport constraint — widening it to "safe" would delete the payload of every QUERY), and the redirect exemption is spec text about QUERY, not a safety rule. `cache.methods` keeps its `['GET','HEAD']` default; QUERY is documented as a valid opt-in entry, not added to it. `StitchConfig.method` widens to `KnownMethod | (string & {})` — an autocomplete list, never an allowlist; a tsd test pins that any string still typechecks. The bundle budget rises 24.10→24.20 / 21.50→21.65 KB (measured 24.12 / 21.56). `main` had one byte of headroom on `import { stitch }`, so the whole +58 B lands over; the advertised figure moves ~21 → ~22 kB across the eight tethered sites. Refs #462 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 31 ++ README.md | 4 +- apps/docs/app/(home)/components/metrics.tsx | 2 +- .../playground-completions.generated.ts | 4 +- .../docs/content/docs/concepts/principles.mdx | 2 +- .../docs/getting-started/installation.mdx | 2 +- .../content/docs/guides/authoring/stitch.mdx | 49 ++- .../docs/guides/resilience/idempotency.mdx | 11 + .../content/docs/reference/config-types.mdx | 8 + apps/docs/lib/source.ts | 2 +- packages/core/README.md | 4 +- packages/core/scripts/bundle-size.mjs | 30 +- packages/core/src/engine.ts | 8 +- packages/core/src/http-adapter.ts | 21 +- packages/core/src/stitch.ts | 11 +- packages/core/src/types.ts | 35 +- packages/core/src/util.ts | 25 ++ packages/core/test-d/known-method.test-d.ts | 37 ++ packages/core/test/query-method.spec.ts | 387 ++++++++++++++++++ 19 files changed, 649 insertions(+), 24 deletions(-) create mode 100644 packages/core/test-d/known-method.test-d.ts create mode 100644 packages/core/test/query-method.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 198ac711..5d7d5f15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,37 @@ npm release are grouped under the in-development version that introduced them. ### Added +- **`QUERY` is a first-class method — a read that carries a request body.** + ([draft-ietf-httpbis-safe-method-w-body](https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/)) + `method: 'QUERY'` already sent its body and already cached correctly under + `cache: { methods: 'QUERY' }`, because the cache key folds the body in. What it did **not** have + was the engine's agreement that it is a read: "safe method" was spelled `=== 'GET' || === 'HEAD'` + inline, so everything else was a write by default. + + One `isSafeMethod` predicate now answers that question in the two places that ask it — RFC 9110 + §9.2.1's safe set (`GET`, `HEAD`, `OPTIONS`, `TRACE`) plus `QUERY`: + + - **No `Idempotency-Key` on a safe method.** A stitch with `idempotency` configured no longer + stamps a dedupe token on a `QUERY` — there is no side effect to collapse, and the header would + have varied the cache key on every send. `OPTIONS`/`TRACE` stop being stamped too; they were + only ever getting a key because they were not `GET` or `HEAD`. + - **The construction nudge follows.** Declaring `idempotency` on a `QUERY` now logs the same + "the key is sent on writes only" hint a `GET` gets, so the drop is never silent. Silenced by + `idempotency.warn = false` as before. + + **A 301/302 no longer downgrades a `QUERY` to a bodyless `GET`** (default `fetch` transport). + That downgrade is a historical exception granted to `POST`, and the draft rules it out by name + for `QUERY`; applying it dropped the body, which silently turned a filtered read into an + unfiltered one. `303` still redirects to a `GET` — for a `QUERY` that is what it means. `POST`, + `PUT`, `PATCH` and `DELETE` redirect exactly as before. + + **Three method tests, not one.** `encodeRequestBody` still drops a body on `GET`/`HEAD` **only** + and is deliberately not routed through the safety predicate: that branch enforces a transport + constraint (`fetch` throws on a `GET` with a body), and widening it to "safe" would have deleted + the payload of every `QUERY` — the one thing that already worked. The cacheable-method default + is likewise untouched at `['GET','HEAD']`; `QUERY` is now documented as a valid `cache.methods` + entry, opt-in like a GraphQL `POST`. + - **`throttle.concurrency` goes fleet-wide too, by lease.** ([ADR 0025](docs/adr/0025-fleet-wide-concurrency-by-lease.md)) [ADR 0024](docs/adr/0024-the-fleet-wide-pacing-cell.md) made the rate budget fleet-wide and left the concurrency cap per-process, so `concurrency: 10` across eight workers was really a fleet cap diff --git a/README.md b/README.md index 59702cf4..84d710d9 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@

- Zero runtime dependencies · ~24 kB min+gzip — a typical import { stitch } tree-shakes to ~21 kB, and with no transitive tree there is nothing else to install or audit. The size is an enforced budget in CI, not an aspiration. + Zero runtime dependencies · ~24 kB min+gzip — a typical import { stitch } tree-shakes to ~22 kB, and with no transitive tree there is nothing else to install or audit. The size is an enforced budget in CI, not an aspiration.

@@ -164,7 +164,7 @@ No server, no codegen, no config files, no implicit inheritance — **only expli - **Pluggable state store** — throttle counters and sessions behind a 3-method store; swap in Redis/Postgres to go distributed. - **Zero-infra observability** — tracing is **off by default**; opt in per stitch or via `STITCH_TRACE_*` env vars. No collector, no dashboard. - **Four front doors, one definition** — in-process function, CLI (`stitch run`), HTTP (`stitch serve`), and MCP (`stitch mcp`). -- **Zero runtime dependencies** — `"dependencies": {}`, built on global `fetch`, tree-shakeable; **~24 kB min+gzip** for the whole entry, **~21 kB** for a typical `import { stitch }`. +- **Zero runtime dependencies** — `"dependencies": {}`, built on global `fetch`, tree-shakeable; **~24 kB min+gzip** for the whole entry, **~22 kB** for a typical `import { stitch }`. ## Install diff --git a/apps/docs/app/(home)/components/metrics.tsx b/apps/docs/app/(home)/components/metrics.tsx index 88db573c..b01ea559 100644 --- a/apps/docs/app/(home)/components/metrics.tsx +++ b/apps/docs/app/(home)/components/metrics.tsx @@ -10,7 +10,7 @@ const metrics = [ body: 'The whole stitchapi entry, tree-shaken — and it is an enforced budget in CI, not an aspiration.', }, { - value: '~21 kB', + value: '~22 kB', unit: 'import { stitch }', body: 'Pay only for what you import: every surface beyond http lives behind its own subpath, so the core trims down.', }, diff --git a/apps/docs/app/(home)/playground/playground-completions.generated.ts b/apps/docs/app/(home)/playground/playground-completions.generated.ts index 7f092f6f..aba57cc7 100644 --- a/apps/docs/app/(home)/playground/playground-completions.generated.ts +++ b/apps/docs/app/(home)/playground/playground-completions.generated.ts @@ -20,8 +20,8 @@ export const PLAYGROUND_COMPLETIONS: Record = { { label: "method", type: "property", - detail: "string", - info: "HTTP method; defaults to `GET`.", + detail: "KnownMethod | (string & {})", + info: "HTTP method; defaults to `GET`. Any verb the transport accepts, including **`QUERY`** — the safe, idempotent, cacheable method that carries a **request body** (`draft-ietf-httpbis-safe-method-w-body`), for a read whose filter is too large or too structured for a URL. The engine classifies `QUERY` as a read: no `Idempotency-Key` is stamped on it, and a 301/302 re-sends it as a `QUERY` rather than downgrading it to a bodyless `GET`. It keeps its body (unlike `GET`/`HEAD`, where the transport forbids one), and it is a valid CacheOptions.methods entry — opt in, since caching a body-carrying request is never a default.", }, { label: "wire", diff --git a/apps/docs/content/docs/concepts/principles.mdx b/apps/docs/content/docs/concepts/principles.mdx index ec2ff9ef..5f36fc9f 100644 --- a/apps/docs/content/docs/concepts/principles.mdx +++ b/apps/docs/content/docs/concepts/principles.mdx @@ -107,7 +107,7 @@ package practices with your bundle. Concretely, the whole `stitch` entry is **~24 kB minified + gzipped** (≈61 kB raw), and because every surface beyond `http` is a separate subpath -import, a typical `import { stitch }` tree-shakes to **~21 kB**. With zero +import, a typical `import { stitch }` tree-shakes to **~22 kB**. With zero runtime dependencies, that figure is the entire cost — not the tip of a transitive tree. diff --git a/apps/docs/content/docs/getting-started/installation.mdx b/apps/docs/content/docs/getting-started/installation.mdx index 04ed85aa..3937b7eb 100644 --- a/apps/docs/content/docs/getting-started/installation.mdx +++ b/apps/docs/content/docs/getting-started/installation.mdx @@ -7,7 +7,7 @@ Install the package, import `stitch`, and turn your first endpoint into a typed, callable function. `stitchapi` has zero dependencies and runs anywhere `fetch` does — Node, the browser, and edge runtimes. The whole entry is **~24 kB minified + gzipped** — and with no dependencies, there is no transitive tree -behind it (a typical `import { stitch }` tree-shakes to ~21 kB). +behind it (a typical `import { stitch }` tree-shakes to ~22 kB). **Validators are bring-your-own.** Because `stitchapi` ships with zero diff --git a/apps/docs/content/docs/guides/authoring/stitch.mdx b/apps/docs/content/docs/guides/authoring/stitch.mdx index 79ae126e..41f8ea02 100644 --- a/apps/docs/content/docs/guides/authoring/stitch.mdx +++ b/apps/docs/content/docs/guides/authoring/stitch.mdx @@ -38,7 +38,8 @@ The handful of fields that shape every stitch: - **`baseUrl`** + **`path`** — the request target. `path` may contain `{param}` slots and a `?query` string; in string form the whole string becomes `path`. -- **`method`** — the HTTP verb. Defaults to `GET`. +- **`method`** — the HTTP verb. Defaults to `GET`. Any verb your transport + accepts, including [`QUERY`](#the-query-method). - **The input object** — `params`, `query`, `headers`, and `body` all travel in one `StitchInput` you pass at call time: `await createUser({ params: { id: 1 } })`. - **The generic** — `stitch(...)` types the awaited value `T`. @@ -49,6 +50,52 @@ The handful of fields that shape every stitch: per feature. +## The QUERY method + +Some reads don't fit in a URL. A faceted search, a big list of ids, a nested +filter — encode it as a query string and you hit length limits, proxies that +truncate, and logs that now hold your filter. The usual workaround is to `POST` +the filter and give up everything a read gets: no caching, and a client that +can't tell the call apart from a write. + +`QUERY` ([`draft-ietf-httpbis-safe-method-w-body`](https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/)) +is the method for exactly that — **safe**, **idempotent**, and **cacheable**, +with a request body. Set it like any other verb: + +```ts +const search = stitch({ + method: 'QUERY', + baseUrl: 'https://api.example.com', + path: '/orders', + // Responses are spec-cacheable, but opt in — the key folds in the body, so two + // different filters get two entries and cannot collide. + cache: { ttl: '1m', methods: 'QUERY' }, +}); + +await search({ body: { status: ['open', 'held'], region: 'eu', limit: 200 } }); +``` + +StitchAPI treats it as the read it is: + +- **The body is sent.** `GET`/`HEAD` have theirs dropped — the transport forbids + one — but `QUERY` keeps it, JSON-encoded like any other body. +- **No idempotency key.** With `idempotency` configured, a `QUERY` is not + stamped with an `Idempotency-Key`; there is no side effect to dedupe. Setting + `idempotency` on one logs the same construction nudge a `GET` gets. +- **A 301/302 stays a `QUERY`.** The downgrade-to-`GET` on a permanent or + temporary redirect is a historical exception granted to `POST`, and the draft + says it does not apply here — downgrading would drop the body, turning a + filtered read into an unfiltered one. A `303` still means "GET the result at + the `Location`", for a `QUERY` as for anything else. +- **`cache.methods` accepts it.** Not in the default `['GET','HEAD']`: like a + GraphQL `POST`, caching a body-carrying request is explicit. + + + `QUERY` is a young method. Check that your server — **and every proxy, CDN, + and WAF between you and it** — actually routes it before reaching for it; + intermediaries have been known to reject or mangle an unfamiliar verb. + + For every field and its default, see [Reference → stitch()](/docs/reference/stitch) and [Reference → Config types](/docs/reference/config-types). diff --git a/apps/docs/content/docs/guides/resilience/idempotency.mdx b/apps/docs/content/docs/guides/resilience/idempotency.mdx index ab1c91f0..31518a8e 100644 --- a/apps/docs/content/docs/guides/resilience/idempotency.mdx +++ b/apps/docs/content/docs/guides/resilience/idempotency.mdx @@ -84,6 +84,17 @@ to silence it when the keyless-retry case (a proxy dedupe, say) is deliberate. See [Reference → Config types](/docs/reference/config-types) for the full `IdempotencyOptions` shape. +## Reads never get a key + +The key rides on **writes only**. Declare `idempotency` on a read and the engine +drops it and logs the same kind of nudge — almost always a missing +`method: 'POST'`. "A read" means every method +[RFC 9110 §9.2.1](https://www.rfc-editor.org/rfc/rfc9110#section-9.2.1) calls +_safe_ — `GET`, `HEAD`, `OPTIONS`, `TRACE` — plus +[`QUERY`](/docs/guides/authoring/stitch#the-query-method), which is safe **and +carries a request body**. Carrying a body is not what makes a call a write, so a +`QUERY` is not stamped: there is no side effect for the server to collapse. + ## See also diff --git a/apps/docs/content/docs/reference/config-types.mdx b/apps/docs/content/docs/reference/config-types.mdx index 46cced5b..d1606b83 100644 --- a/apps/docs/content/docs/reference/config-types.mdx +++ b/apps/docs/content/docs/reference/config-types.mdx @@ -136,6 +136,14 @@ request, so it cannot drift from what it names. +`methods` defaults to `['GET','HEAD']`. Two body-carrying reads opt in by naming +their method: a GraphQL **query** (`methods: 'POST'`) and +[`QUERY`](/docs/guides/authoring/stitch#the-query-method) +(`methods: 'QUERY'`), whose responses are cacheable by spec. Neither is a default — +a `POST`'s read-vs-mutate intent cannot be inferred, and caching a body-carrying +request is a decision worth writing down. The key already folds the request body in, +so two different `QUERY` bodies to the same URL get two entries and cannot collide. + #### CacheFingerprintOptions The shape behind `cache.fingerprint` — how a stored value is detected as stale against diff --git a/apps/docs/lib/source.ts b/apps/docs/lib/source.ts index e8ff2e49..f9a4b195 100644 --- a/apps/docs/lib/source.ts +++ b/apps/docs/lib/source.ts @@ -25,7 +25,7 @@ Search these docs instead of loading the whole file: this site is also a hosted - Capability, not credential: an agent invokes a stitch and gets structured, validated, traceable data; the secret stays behind the boundary. - One context-frugal **code-mode** tool (run_stitch + list_stitches + describe_stitch), not one tool per endpoint — adding APIs never floods the context window. - No server, no codegen, no config files — a URL and one example response is enough; only explicit composition (no ambient/global config a stitch silently inherits). -- Zero-dependency core, ~24 kB min+gzip for the whole entry (~21 kB for a tree-shaken import { stitch }), validator-agnostic (bring your own Standard Schema / Zod), and it runs in the browser. +- Zero-dependency core, ~24 kB min+gzip for the whole entry (~22 kB for a tree-shaken import { stitch }), validator-agnostic (bring your own Standard Schema / Zod), and it runs in the browser. - Composes with your data layer: a stitch is the queryFn for TanStack Query / SWR — it owns the call's resilience; your query layer owns view state. ## Quickstart diff --git a/packages/core/README.md b/packages/core/README.md index 00fd280b..b8c586b2 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -120,7 +120,7 @@ No server, no codegen, no config files, no implicit inheritance — **only expli - **CLI, HTTP & MCP surfaces** - the definition your code imports is also runnable from the shell (`stitch run ` streams JSONL events), served over HTTP (`stitch serve`), or exposed to agents over MCP (`stitch mcp`) — the same stitch behind every front door. - **Typed URLs** - full [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) URI templates (`{id}`, `{+path}`, `{?q,sort}`, explode `*`, prefix `:n`), and a `qs`-style query builder that serializes nested objects (`a[b]=c`) and arrays — both dependency-free. - **Pluggable transport** - `fetch` by default; drop in the shipped `axiosAdapter`, or any `Adapter` function, to route requests through axios or another HTTP client. -- **Zero runtime dependencies** - `"dependencies": {}`; built on the platform's global `fetch`; tree-shakeable. The whole entry is **~24 kB min+gzip**; a typical `import { stitch }` trims to **~21 kB** — and with no transitive tree, that is the entire cost. +- **Zero runtime dependencies** - `"dependencies": {}`; built on the platform's global `fetch`; tree-shakeable. The whole entry is **~24 kB min+gzip**; a typical `import { stitch }` trims to **~22 kB** — and with no transitive tree, that is the entire cost. ## Documentation @@ -162,7 +162,7 @@ const { stitch } = require("stitchapi"); The runtime ships with zero dependencies. Schema validation is bring-your-own — pass a [Zod](https://zod.dev) schema or any [Standard Schema](https://standardschema.dev) validator ([Valibot](https://valibot.dev), [ArkType](https://arktype.io), …); none of them is bundled. The examples below use Zod for familiarity. -**Bundle size.** The whole `stitchapi` entry is **~24 kB minified + gzipped** (66 kB raw, ~21 kB brotli); because the package is side-effect-free and every surface beyond `http` lives behind its own subpath import, a typical `import { stitch }` tree-shakes to **~21 kB min+gzip**. With zero dependencies, that is the _whole_ cost — there is no transitive tree to install or audit. +**Bundle size.** The whole `stitchapi` entry is **~24 kB minified + gzipped** (66 kB raw, ~22 kB brotli); because the package is side-effect-free and every surface beyond `http` lives behind its own subpath import, a typical `import { stitch }` tree-shakes to **~22 kB min+gzip**. With zero dependencies, that is the _whole_ cost — there is no transitive tree to install or audit. ## Quick start diff --git a/packages/core/scripts/bundle-size.mjs b/packages/core/scripts/bundle-size.mjs index 55a338af..11976a73 100644 --- a/packages/core/scripts/bundle-size.mjs +++ b/packages/core/scripts/bundle-size.mjs @@ -273,19 +273,45 @@ const KB = 1024; // are UNCHANGED at 24 / 21 this time — 23.91 still rounds to 24 — so no README or docs figure // moves; verified against the `bundle-advertised-size` tether rather than assumed, which is the // mistake the ADR 0024 raise made. +// Budgets raised for the first-class QUERY method — issue #462 part 1 (24.10→24.20 / +// 21.50→21.65 KB; measured 24.12 / 21.56 against a `main` at 24.08 / 21.50, so the whole change +// is +42 / +58 BYTES). `main` had 0.02 KB left on the entry and ONE byte on `import { stitch }` +// (22015 of a 22016 B ceiling), so the entry was full in the literal sense and the next core-path +// change of any size was going to pay for the raise. This is that change. +// +// What the bytes buy: one named `isSafeMethod` predicate (RFC 9110 §9.2.1's safe set plus QUERY) +// replacing the inline `=== 'GET' || === 'HEAD'` in `applyIdempotency` and in the construction +// nudge that mirrors it, so a QUERY — a READ that carries a request body — stops being stamped +// with an `Idempotency-Key`; plus the QUERY exemption from the 301/302 downgrade-to-GET, which +// draft-ietf-httpbis-safe-method-w-body requires by name. Attributed exactly: +33 B is the helper +// and its two rerouted call sites, +21 B is carrying OPTIONS/TRACE in the safe set so the +// predicate means what RFC 9110 says it means, +4 B is the redirect exemption. None of it can +// move behind a subpath — `applyIdempotency` is in `buildRequest`, the nudge is in `makeStitch`, +// and `fetchAdapter` is the default transport; all three run for every stitch. +// +// A MINIMUM step (0.08 / 0.09 KB headroom), not the ~0.2 KB this gate usually restores. The +// change is 58 bytes and the entry is full: keeping the ceiling tight keeps that signal, exactly +// as #477/#524/#485 did. +// +// The ADVERTISED figure moves for `import { stitch }`: 22015 B is 21.499 KB and rounds to 21, +// 22073 B is 21.556 and rounds to 22, so every site quoting it goes ~21 → ~22 kB (the whole entry +// stays ~24). Eight sites, propagated under the `bundle-advertised-size` drift tether — both +// READMEs, the installation and principles pages, the home-page metrics component, and the docs' +// own source blurb. Recorded here because it is the number the project advertises, and a raise +// that left the docs claiming the old one is the exact drift that tether exists to catch. // `advertised: true` means the READMEs/docs quote this scenario's rounded gzip kB — see the // `--json` note below for why that flag, not the row's presence, drives the drift tether. const SCENARIOS = [ { name: 'stitchapi — whole entry', code: `export * from './index.mjs';`, - budget: 24.1 * KB, + budget: 24.2 * KB, advertised: true, }, { name: 'import { stitch }', code: `export { stitch } from './index.mjs';`, - budget: 21.5 * KB, + budget: 21.65 * KB, advertised: true, }, { diff --git a/packages/core/src/engine.ts b/packages/core/src/engine.ts index 481fd349..ad1ac55c 100644 --- a/packages/core/src/engine.ts +++ b/packages/core/src/engine.ts @@ -44,6 +44,7 @@ import { buildQuery, expandPath, getPath, + isSafeMethod, newRunContext, now, parseDuration, @@ -156,7 +157,10 @@ function joinUrl(base: string, path: string): string { // Inject a stable Idempotency-Key on writes. The key is computed once per logical call (here, // in buildRequest) and the attempt loop reuses the same request, so it stays constant across -// retries. GET/HEAD are skipped, and a caller-provided header (case-insensitive) wins. +// retries. SAFE methods are skipped, and a caller-provided header (case-insensitive) wins. +// "Safe" rather than "GET/HEAD" because QUERY is a read that carries a body: stamping a +// dedupe token on a request that changes nothing is a category error, and the header would +// vary the cache key on every send. function applyIdempotency( cfg: ResolvedStitchConfig, input: StitchInput, @@ -164,7 +168,7 @@ function applyIdempotency( headers: Record, ): void { if (!cfg.idempotency) return; - if (method === 'GET' || method === 'HEAD') return; // writes only + if (isSafeMethod(method)) return; // writes only const header = cfg.idempotency.header ?? 'Idempotency-Key'; if ( Object.keys(headers).some( diff --git a/packages/core/src/http-adapter.ts b/packages/core/src/http-adapter.ts index 1318535e..dc72b4da 100644 --- a/packages/core/src/http-adapter.ts +++ b/packages/core/src/http-adapter.ts @@ -239,9 +239,23 @@ async function followRedirects( // Fetch redirect model: 303 (and 301/302 on a non-GET/HEAD) become a bodyless GET; 307/308 // keep method and body. Rebuild headers without content-type when the body is dropped — // and always via a fresh object, since a same-origin headersForRedirect aliased the input. + // + // QUERY is exempt from the 301/302 arm, and this is spec text, not a safety inference: + // the downgrade-to-GET is a HISTORICAL exception granted to POST (RFC 9110 §15.4.2/3), + // and draft-ietf-httpbis-safe-method-w-body says of it in as many words — "the exceptions + // for redirecting a POST as a GET request after a 301 or 302 response do not apply to + // QUERY requests"; the server is asking for "a similar QUERY request to the new target + // URI". Downgrading would drop the body, and the body is the query, so the redirect + // would silently turn a filtered read into an unfiltered one. NOT `isSafeMethod`: + // OPTIONS and TRACE are safe too and no spec exempts them, so they keep today's + // behaviour. 303 stays unconditional — the draft agrees that a 303 to a QUERY means the + // result "can be accomplished via a normal retrieval request" at the Location. if ( status === 303 || - (status < 303 && method !== 'GET' && method !== 'HEAD') + (status < 303 && + method !== 'GET' && + method !== 'HEAD' && + method !== 'QUERY') ) { method = 'GET'; body = undefined; @@ -322,6 +336,11 @@ export function encodeRequestBody(req: AdapterRequest): { contentType?: string; } { const method = req.method.toUpperCase(); + // GET/HEAD only — deliberately NOT `isSafeMethod`. This is a TRANSPORT constraint, not a + // safety rule: `fetch` throws a TypeError when a GET/HEAD init carries a body, and XHR + // ignores it. QUERY is equally safe and MUST keep its body — the body IS the query + // (draft-ietf-httpbis-safe-method-w-body) — so widening this to "safe" would silently + // delete the payload of every QUERY request. if (method === 'GET' || method === 'HEAD') return { body: undefined }; if (req.body === undefined || req.body === null) return { body: undefined }; if (typeof req.body === 'string') return { body: req.body }; diff --git a/packages/core/src/stitch.ts b/packages/core/src/stitch.ts index 903e533b..2c642f4d 100644 --- a/packages/core/src/stitch.ts +++ b/packages/core/src/stitch.ts @@ -72,6 +72,7 @@ import { import { deepMerge, envelope, + isSafeMethod, newRunContext, readEnv, redactSecretsDeep, @@ -382,8 +383,12 @@ export function compose(config: Fragment): ResolvedStitchConfig { // both silenced by `idempotency.warn = false` and both scoped to the **default HTTP surface**: a // surface (graphql → POST) can force the method after construction, so its writes aren't knowable // here, and we don't guess. -// 1. On a read (GET/HEAD) the engine drops the key (writes only) — almost always a missing -// `method`, so the write protection the author expects silently isn't there. +// 1. On a read (any SAFE method — GET/HEAD, and QUERY, which is a read that carries a body) +// the engine drops the key (writes only) — almost always a missing `method`, so the write +// protection the author expects silently isn't there. This shares `isSafeMethod` with +// `applyIdempotency` rather than restating GET/HEAD, so the nudge cannot drift out of step +// with the drop it is warning about: a method the engine silently skips is a method this +// says so about. // 2. The *random* default key only dedupes a replay of the same request, and `retry` is what // replays it; with no `retry` it usually has nothing to collapse. (Not useless in every case — // a proxy/transport resending the request below the stitch carries the same key for a server @@ -424,7 +429,7 @@ function warnConstruction(cfg: ResolvedStitchConfig): void { // method semantics" skip has to ask which surface — not whether there is one. if (!idem || idem.warn === false || cfg.kind.id !== 'http') return; const method = (cfg.method ?? 'GET').toUpperCase(); - if (method === 'GET' || method === 'HEAD') { + if (isSafeMethod(method)) { console.warn( `stitchapi: \`${name}\` sets \`idempotency\` on a ${method}, but the key is sent on ` + `writes only — set \`method: 'POST'\`, or drop \`idempotency\`.`, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 78b0861f..bdb07a52 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1211,9 +1211,11 @@ export interface CircuitOptions { * set a correlation/trace header like `traceparent` or `X-Request-Id`; those identify a request * for logs and spans and belong to tracing, not dedupe. * - * The key is sent on **writes only**; setting `idempotency` on a read (GET/HEAD) drops it and logs - * a construction nudge — almost always a missing `method: 'POST'`. Both nudges fire only on the - * default HTTP surface and are silenced by `warn: false`. + * The key is sent on **writes only**; setting `idempotency` on a read drops it and logs a + * construction nudge — almost always a missing `method: 'POST'`. "A read" is every method RFC 9110 + * §9.2.1 calls *safe* (GET, HEAD, OPTIONS, TRACE) plus `QUERY`, which is safe but carries a body — + * so a body on the request is not what makes it a write. Both nudges fire only on the default HTTP + * surface and are silenced by `warn: false`. */ export interface IdempotencyOptions { /** @@ -1327,6 +1329,12 @@ export interface CacheOptions { * its method (`'POST'`) — a POST's read-vs-mutate intent cannot be inferred, so it is * explicit. Coalescing applies to exactly this set; mutations are never cached. A bare string * is shorthand for a one-element list (CONTRACT.md P7). + * + * `'QUERY'` is a valid entry, and its responses are spec-cacheable + * ([draft-ietf-httpbis-safe-method-w-body](https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/)). + * It is **not** in the default set — like a GraphQL POST, caching a body-carrying request is + * opt-in. The key already folds the request body in, so two different `QUERY` bodies to the + * same URL get two entries and cannot collide: `cache: { ttl: '1m', methods: 'QUERY' }`. */ methods?: string | string[]; /** In-process LRU cap on live entries (the store stays dumb). Default 1000. */ @@ -1577,6 +1585,14 @@ export interface PaginateOptions { /** Safety cap on pages. Default 50. */ pages?: number; } +/** + * The HTTP methods StitchAPI knows about — the RFC 9110 verbs plus `QUERY` + * (`draft-ietf-httpbis-safe-method-w-body`). Purely an **autocomplete list**: `method` is + * `KnownMethod | (string & {})`, so any other verb your transport accepts still typechecks. + */ +export type KnownMethod = + 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'QUERY'; + export interface StitchConfig { /** Label used in events and traces; defaults to `path` or `'stitch'`. */ name?: string; @@ -1586,8 +1602,17 @@ export interface StitchConfig { * declaration round-trips as JSON — Decision 11); the live object stays on `__rawConfig`. */ kind?: Surface; - /** HTTP method; defaults to `GET`. */ - method?: string; + /** + * HTTP method; defaults to `GET`. Any verb the transport accepts, including **`QUERY`** — the + * safe, idempotent, cacheable method that carries a **request body** + * (`draft-ietf-httpbis-safe-method-w-body`), for a read whose filter is too large or too + * structured for a URL. The engine classifies `QUERY` as a read: no `Idempotency-Key` is + * stamped on it, and a 301/302 re-sends it as a `QUERY` rather than downgrading it to a + * bodyless `GET`. It keeps its body (unlike `GET`/`HEAD`, where the transport forbids one), + * and it is a valid {@link CacheOptions.methods} entry — opt in, since caching a + * body-carrying request is never a default. + */ + method?: KnownMethod | (string & {}); /** * Wire-format options — request body encoding, response decoding, and urlencoded array * serialisation, grouped by category rather than by request/response phase (CONTRACT.md P24). diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts index ca65c69c..05d22277 100644 --- a/packages/core/src/util.ts +++ b/packages/core/src/util.ts @@ -220,6 +220,31 @@ export function stripTrailingSlashes(s: string): string { return end === s.length ? s : s.slice(0, end); } +// ---- HTTP method classification ------------------------------------------- +// RFC 9110 §9.2.1's safe set — "essentially read-only", so a server may treat the request as +// having no side effect — plus QUERY (draft-ietf-httpbis-safe-method-w-body: "QUERY requests +// are safe with regard to the target resource"). QUERY is the reason this set exists as a named +// predicate rather than a fourth inline `=== 'GET' || === 'HEAD'`: it is a **read that carries a +// request body**, so "safe" and "GET-shaped" stopped being the same question. +// +// This answers exactly one question — *is this request a read?* — and is deliberately NOT used +// for the other two method tests in the transport, which look alike and are not: +// • `encodeRequestBody` drops the body on GET/HEAD only. That is a fetch/XHR constraint (a +// GET with a body is a TypeError), not a safety rule — routing it through here would delete +// the body of every QUERY, i.e. the one thing that already worked. +// • `followRedirects` exempts GET/HEAD/QUERY from the 301/302 downgrade-to-GET. That is the +// draft's explicit carve-out from a HISTORICAL POST exception, not a safety rule — OPTIONS +// and TRACE are safe and are not exempted by any spec text. +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE', 'QUERY']); + +/** + * Is `method` **safe** (RFC 9110 §9.2.1) — a read, requesting no change to the target resource? + * Safe methods are also idempotent, so this is the predicate for "the engine must not treat this + * as a write". Case-insensitive; an unknown verb is assumed unsafe (fail closed). + */ +export const isSafeMethod = (method: string): boolean => + SAFE_METHODS.has(method.toUpperCase()); + export const isObj = (x: unknown): x is Record => !!x && typeof x === 'object' && !Array.isArray(x); diff --git a/packages/core/test-d/known-method.test-d.ts b/packages/core/test-d/known-method.test-d.ts new file mode 100644 index 00000000..6a238e04 --- /dev/null +++ b/packages/core/test-d/known-method.test-d.ts @@ -0,0 +1,37 @@ +// `StitchConfig.method` is `KnownMethod | (string & {})` (issue #462 part 1, gap 4): the union arm +// gives an IDE the eight verbs to autocomplete — `QUERY` among them, which is the whole point, since +// nothing else would tell an author it exists — while `string & {}` keeps the field open. Widening a +// `string` this way is only safe if it is PURELY additive, so that is what this pins: everything that +// typechecked before still does. +import { stitch } from '../src'; +import type { KnownMethod, Stitch } from '../src'; + +import { expectAssignable, expectType } from 'tsd'; + +// ── The known verbs, including QUERY ──────────────────────────────────────── +expectType>( + stitch({ url: 'https://api.example.com/orders', method: 'QUERY' }), +); +for (const m of [ + 'GET', + 'HEAD', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'OPTIONS', + 'QUERY', +] as const) + expectAssignable(m); + +// ── Still open: a custom verb is not an error ─────────────────────────────── +// The door stays open for WebDAV, a vendor verb, or a method the spec adds next — the union is an +// autocomplete list, never an allowlist. +expectType>( + stitch({ url: 'https://api.example.com/x', method: 'PROPFIND' }), +); +// Including a method that is only known at runtime, which a closed union would have rejected. +declare const runtimeVerb: string; +expectType>( + stitch({ url: 'https://api.example.com/x', method: runtimeVerb }), +); diff --git a/packages/core/test/query-method.spec.ts b/packages/core/test/query-method.spec.ts new file mode 100644 index 00000000..0500248e --- /dev/null +++ b/packages/core/test/query-method.spec.ts @@ -0,0 +1,387 @@ +// QUERY as a first-class method (issue #462 part 1). +// +// QUERY is the safe, idempotent, *cacheable* method that carries a request body +// (draft-ietf-httpbis-safe-method-w-body) — "a GET whose filter is too big or too structured for a +// URL". It worked here by accident: the engine's notion of a safe method was hard-coded to +// GET/HEAD in four places, so a QUERY was treated as a write everywhere except the one branch that +// happened to let its body through. +// +// The three method tests in the transport LOOK alike and ask different questions; this suite pins +// each one separately, because conflating them is exactly how a QUERY loses its body: +// • `isSafeMethod` — "is this a read?" → no Idempotency-Key, and the construction nudge that +// mirrors that drop. GET/HEAD/OPTIONS/TRACE/QUERY. +// • `encodeRequestBody` — "may this method carry a body on the wire?" → GET/HEAD only (a +// transport constraint: `fetch` throws on a GET with a body). QUERY keeps its body. +// • `followRedirects` — "does a 301/302 downgrade this to a bodyless GET?" → the historical POST +// exception, which the draft says explicitly does NOT apply to QUERY. GET/HEAD/QUERY exempt. +// +// Every GET/HEAD/POST assertion below is a regression guard on unchanged behaviour, not new +// behaviour. +import { fetchAdapter, stitch } from '../src'; +import { encodeRequestBody } from '../src/http-adapter'; +import { mockAdapter } from '../src/testing'; +import type { Adapter, AdapterRequest } from '../src/types'; +import { isSafeMethod } from '../src/util'; +import { startMockServer } from './support/mock-server'; +import type { MockServer } from './support/mock-server'; + +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +// Keep this suite's trace sink out of the console (matches the other transport specs). +process.env['STITCH_TRACE_FILE'] = join( + tmpdir(), + `stitch-query-method-${process.pid}.jsonl`, +); + +const req = (over: Partial = {}): AdapterRequest => ({ + url: 'https://api.test/search', + method: 'QUERY', + headers: {}, + ...over, +}); + +// Pull a header case-insensitively from a recorded request. +const hdr = (h: Record, name: string): string | undefined => { + const k = Object.keys(h).find( + (x) => x.toLowerCase() === name.toLowerCase(), + ); + return k ? h[k] : undefined; +}; + +// A scripted fetch that answers a redirect chain and records the method/body/url of every hop, so +// a test can assert what the redirect TARGET was actually sent. Typed as `typeof fetch` (the arrow +// is structurally assignable, so no cast is needed). +function redirectingFetch(steps: { status: number; location?: string }[]): { + fetch: typeof fetch; + hops: { url: string; method: string; body: unknown }[]; +} { + const hops: { url: string; method: string; body: unknown }[] = []; + let i = 0; + const fetch: typeof globalThis.fetch = (input, init) => { + hops.push({ + url: input as string, + method: init?.method ?? 'GET', + body: init?.body, + }); + const step = steps[Math.min(i, steps.length - 1)]; + i++; + const headers = new Headers(); + if (step?.location) headers.set('location', step.location); + const redirecting = + step !== undefined && step.status >= 300 && step.status < 400; + if (!redirecting) headers.set('content-type', 'application/json'); + return Promise.resolve( + new Response(redirecting ? null : '{"ok":true}', { + status: step?.status ?? 200, + headers, + }), + ); + }; + return { fetch, hops }; +} + +describe('isSafeMethod — one predicate for "is this a read?"', () => { + test('RFC 9110 §9.2.1 safe set, plus QUERY', () => { + for (const m of ['GET', 'HEAD', 'OPTIONS', 'TRACE', 'QUERY']) + expect(isSafeMethod(m)).toBe(true); + // Case-insensitive: `cfg.method` is uppercased by the engine, but the helper is also + // reachable from an adapter, where the method is whatever the caller wrote. + expect(isSafeMethod('query')).toBe(true); + }); + + test('writes, and an unknown verb, are not safe (fail closed)', () => { + for (const m of ['POST', 'PUT', 'PATCH', 'DELETE', 'PURGE', '']) + expect(isSafeMethod(m)).toBe(false); + }); +}); + +describe('encodeRequestBody — a QUERY keeps its body', () => { + test('the body survives, JSON-encoded, with a content-type', () => { + const out = encodeRequestBody( + req({ body: { filter: { tags: ['a', 'b'] } } }), + ); + expect(out.body).toBe('{"filter":{"tags":["a","b"]}}'); + expect(out.contentType).toBe('application/json'); + }); + + test('a QUERY form/multipart body encodes like any other body-bearing method', () => { + expect( + encodeRequestBody(req({ body: { a: 1, b: 2 }, bodyType: 'form' })) + .body, + ).toBe('a=1&b=2'); + expect( + encodeRequestBody(req({ body: { f: 'v' }, bodyType: 'multipart' })) + .body, + ).toBeInstanceOf(FormData); + }); + + test('GET/HEAD still drop a body — the transport, not safety, is what decides here', () => { + expect( + encodeRequestBody(req({ method: 'GET', body: { a: 1 } })).body, + ).toBeUndefined(); + expect( + encodeRequestBody(req({ method: 'HEAD', body: { a: 1 } })).body, + ).toBeUndefined(); + // OPTIONS is safe too, and is NOT body-stripped — proof this branch is not `isSafeMethod`. + expect( + encodeRequestBody(req({ method: 'OPTIONS', body: { a: 1 } })).body, + ).toBe('{"a":1}'); + }); +}); + +describe('idempotency — a safe method is never stamped with a key', () => { + // Drive the engine through the published mock transport and read the request it built. + const sent = async (method: string): Promise => { + const api = mockAdapter({ respond: { body: { ok: true } } }); + const s = stitch({ + method, + url: 'https://api.test/search', + adapter: api, + trace: false, + idempotency: { warn: false }, // the nudge is asserted separately, below + }); + await s({ body: { q: 'ada' } }); + return api.lastRequest() as AdapterRequest; + }; + + test('a QUERY gets no Idempotency-Key — and still sends its body', async () => { + const r = await sent('QUERY'); + expect(r.method).toBe('QUERY'); + expect(hdr(r.headers, 'idempotency-key')).toBeUndefined(); + expect(r.body).toEqual({ q: 'ada' }); + }); + + test('GET/HEAD are unchanged, and OPTIONS/TRACE now classify as the reads they are', async () => { + for (const m of ['GET', 'HEAD', 'OPTIONS', 'TRACE']) + expect(hdr((await sent(m)).headers, 'idempotency-key')).toBe( + undefined, + ); + }); + + test('a write still gets one — POST/PUT/PATCH/DELETE are untouched', async () => { + for (const m of ['POST', 'PUT', 'PATCH', 'DELETE']) + expect( + hdr((await sent(m)).headers, 'idempotency-key'), + ).toBeTruthy(); + }); + + test('a caller-supplied key still wins on a write, and is not invented on a QUERY', async () => { + const api = mockAdapter({ respond: { body: { ok: true } } }); + const make = (method: string): ReturnType => + stitch({ + method, + url: 'https://api.test/search', + adapter: api, + trace: false, + idempotency: { warn: false }, + }); + await make('POST')({ headers: { 'Idempotency-Key': 'mine' } }); + expect(hdr(api.lastRequest()!.headers, 'idempotency-key')).toBe('mine'); + await make('QUERY')({ headers: { 'Idempotency-Key': 'mine' } }); + expect(hdr(api.lastRequest()!.headers, 'idempotency-key')).toBe('mine'); + }); +}); + +describe('the construction nudge tracks the drop it warns about', () => { + const nudgeFor = (method: string): string | undefined => { + const warn = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + try { + stitch({ + method, + url: 'https://api.test/search', + trace: false, + idempotency: true, + }); + return warn.mock.calls[0]?.[0] as string | undefined; + } finally { + warn.mockRestore(); + } + }; + + test('a QUERY with `idempotency` is nudged, exactly as a GET is', () => { + // Without this the key would be silently dropped with nothing said — the failure mode the + // nudge exists to prevent, reintroduced by the engine-side fix. + const msg = nudgeFor('QUERY'); + expect(msg).toContain('writes only'); + expect(msg).toContain('QUERY'); + expect(nudgeFor('GET')).toContain('writes only'); // unchanged + }); + + test('a write is not nudged for being a read (it has a `retry`, so no second nudge either)', () => { + const warn = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + try { + stitch({ + method: 'POST', + url: 'https://api.test/search', + trace: false, + retry: { attempts: 2 }, + idempotency: true, + }); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); +}); + +describe('redirects — a 301/302 does not downgrade a QUERY', () => { + test('a 301 re-sends the QUERY, body intact, to the new target', async () => { + const { fetch: spy, hops } = redirectingFetch([ + { status: 301, location: 'https://api.test/v2/search' }, + { status: 200 }, + ]); + await fetchAdapter({ fetch: spy })({ + url: 'https://api.test/search', + method: 'QUERY', + headers: {}, + body: { q: 'ada' }, + }); + expect(hops).toHaveLength(2); + expect(hops[1]?.url).toBe('https://api.test/v2/search'); + expect(hops[1]?.method).toBe('QUERY'); + expect(hops[1]?.body).toBe('{"q":"ada"}'); + }); + + test('a 302 likewise — while a POST is still downgraded to a bodyless GET', async () => { + const script = [ + { status: 302, location: 'https://api.test/moved' }, + { status: 200 }, + ]; + const q = redirectingFetch(script); + await fetchAdapter({ fetch: q.fetch })({ + url: 'https://api.test/search', + method: 'QUERY', + headers: {}, + body: { q: 'ada' }, + }); + expect(q.hops[1]?.method).toBe('QUERY'); + + const p = redirectingFetch(script); + await fetchAdapter({ fetch: p.fetch })({ + url: 'https://api.test/search', + method: 'POST', + headers: {}, + body: { q: 'ada' }, + }); + expect(p.hops[1]?.method).toBe('GET'); // the historical POST exception, unchanged + expect(p.hops[1]?.body).toBeUndefined(); + }); + + test('a 303 still means "GET the result over there", for a QUERY too', async () => { + const { fetch: spy, hops } = redirectingFetch([ + { status: 303, location: 'https://api.test/results/7' }, + { status: 200 }, + ]); + await fetchAdapter({ fetch: spy })({ + url: 'https://api.test/search', + method: 'QUERY', + headers: {}, + body: { q: 'ada' }, + }); + expect(hops[1]?.method).toBe('GET'); + expect(hops[1]?.body).toBeUndefined(); + }); + + test('a 307 keeps method and body, as it always did', async () => { + const { fetch: spy, hops } = redirectingFetch([ + { status: 307, location: 'https://api.test/v2/search' }, + { status: 200 }, + ]); + await fetchAdapter({ fetch: spy })({ + url: 'https://api.test/search', + method: 'QUERY', + headers: {}, + body: { q: 'ada' }, + }); + expect(hops[1]?.method).toBe('QUERY'); + expect(hops[1]?.body).toBe('{"q":"ada"}'); + }); +}); + +describe('cache — QUERY is opt-in, and keys on the request body', () => { + // Counts origin calls so "served from cache" is observable as "the origin was not called". + const counting = (): { adapter: Adapter; calls: () => number } => { + let calls = 0; + const adapter: Adapter = (r) => + Promise.resolve({ + status: 200, + headers: {}, + body: { n: (calls += 1), echo: r.body }, + }); + return { adapter, calls: () => calls }; + }; + + test('`methods: "QUERY"` caches; two different bodies do not collide', async () => { + const { adapter, calls } = counting(); + const search = stitch({ + method: 'QUERY', + url: 'https://api.test/search', + adapter, + trace: false, + cache: { ttl: '60s', tenancy: 'app', methods: 'QUERY' }, + }); + expect(await search({ body: { q: 'ada' } })).toEqual({ + n: 1, + echo: { q: 'ada' }, + }); + expect(await search({ body: { q: 'ada' } })).toEqual({ + n: 1, + echo: { q: 'ada' }, + }); // hit + expect(calls()).toBe(1); + // A different body is a different query — it must MISS, not be served the first answer. + expect(await search({ body: { q: 'grace' } })).toEqual({ + n: 2, + echo: { q: 'grace' }, + }); + expect(calls()).toBe(2); + }); + + test('the default `[GET, HEAD]` still does not cache a QUERY (opt-in, unchanged)', async () => { + const { adapter, calls } = counting(); + const search = stitch({ + method: 'QUERY', + url: 'https://api.test/search', + adapter, + trace: false, + cache: { ttl: '60s', tenancy: 'app' }, + }); + await search({ body: { q: 'ada' } }); + await search({ body: { q: 'ada' } }); + expect(calls()).toBe(2); + }); +}); + +describe('end to end, over a real socket', () => { + let server: MockServer; + beforeAll(async () => { + server = await startMockServer(); + }); + afterAll(async () => { + await server.close(); + }); + + test('a QUERY stitch sends its body and no Idempotency-Key', async () => { + server.route('QUERY', '/search', { body: { hits: 2 } }); + const search = stitch({ + method: 'QUERY', + baseUrl: server.url, + path: '/search', + trace: false, + idempotency: { warn: false }, + }); + expect(await search({ body: { filter: { tag: 'ada' } } })).toEqual({ + hits: 2, + }); + const call = server.calls('/search')[0]!; + expect(call.method).toBe('QUERY'); + expect(call.body).toEqual({ filter: { tag: 'ada' } }); + expect(call.headers['content-type']).toBe('application/json'); + expect(call.headers['idempotency-key']).toBeUndefined(); + }); +});