diff --git a/.changeset/client-envelope-response-headers.md b/.changeset/client-envelope-response-headers.md new file mode 100644 index 0000000000..3f11a91811 --- /dev/null +++ b/.changeset/client-envelope-response-headers.md @@ -0,0 +1,7 @@ +--- +'@redocly/client-generator': minor +--- + +Added an opt-in success envelope to throw-mode calls: pass `{ envelope: true }` to get `{ data, headers, response }` instead of the body alone. +`headers` is a typed camelCase object built from the operation's declared success-response headers; `response` is the raw `Response`. +Default call sites stay body-only, and the TanStack Query and SWR wrappers exclude the option. diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 044af2ea1f..bdb88aad12 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -280,7 +280,39 @@ const res = await getMenuItemPhoto('prd_123', { parseAs: 'stream' }); `parseAs` accepts `'json'`, `'text'`, `'blob'`, `'arrayBuffer'`, `'formData'`, `'stream'`, or `'auto'` (default). It changes the runtime reader only, not the static return type. -An operation whose success response declares no content is typed `void`, but if the server sends a JSON body anyway (a gap in the API description), the runtime still parses and returns it rather than silently dropping real data — reach it with a cast while the description catches up. +An operation whose success response declares no content is typed `void`. +However, if the server sends a JSON body anyway (a gap in the API description), the runtime still parses and returns it rather than silently dropping real data. +Reach it with a cast while the description catches up. + +## Response headers (envelope) + +By default throw mode returns only the parsed success body. +When you need response headers (pagination totals, rate limits, `Location`, and so on) without switching to `--error-mode result`, pass `{ envelope: true }` on that call: + +```ts +// Flat args (default): query/body slots, then per-call init. +const { data, headers, response } = await listCustomers({ limit: 1 }, { envelope: true }); + +headers.paginationTotal; // number — required Pagination-Total in the description +headers.xFlag; // boolean | undefined — optional X-Flag +response.headers.get('X-Undocumented'); // anything not declared in OpenAPI + +// Grouped args / instance client: trailing init is always separate. +const envelope = await client.listCustomers({ params: { limit: 1 } }, { envelope: true }); +``` + +- `headers` is a safe camelCase object of headers declared on the operation's success response. + String, number, and boolean schemas drive the TypeScript type and number/boolean coercion. + Complex header schemas remain strings because HTTP exposes header values as text. + Required response headers are required properties — the type trusts the API description, the same way response body types do. + Colliding normalized names get a deterministic numeric suffix. +- `response` is the raw `Response` — use it for undocumented headers. +- Non-2xx responses still throw `ApiError`. +- Default call sites stay body-only (non-breaking), including calls that pass other options (`headers`, `signal`, `parseAs`, a retry override). +- In `--error-mode result` the flag is ignored; that mode already returns `response`. +- The TanStack Query and SWR wrappers don't accept `envelope`. + It's excluded from their options and stripped from the forwarded call, so cached data is always the plain body. + Call the sdk function directly when you need headers. ## Runtime validation diff --git a/packages/client-generator/CONTEXT.md b/packages/client-generator/CONTEXT.md index 702d4501bd..908687c0ea 100644 --- a/packages/client-generator/CONTEXT.md +++ b/packages/client-generator/CONTEXT.md @@ -77,7 +77,7 @@ The zero-dependency client engine — real TypeScript modules under `src/runtime Uses only web-standard APIs. `createClient(operations, config)` builds a typed **instance client** over the operation descriptors: one bound method per operation plus the core members `configure` / `use` / `auth`; optional behaviors (multipart serialization, auth injection, SSE streaming) are dispatched through the **capability seam** (`Capabilities`, `runtime/create-client.ts`) and never statically imported by the core. Package mode imports the barrel (`runtime/index.ts`, all capabilities wired); inline mode embeds the same sources — snapshotted into the generated `emitters/runtime-sources.ts` and assembled per the API's needs by `emitters/inline-runtime.ts`, which appends a local `createClient` factory wiring only the needed capabilities. -`parse` decodes a success body into the requested kind — `json` / `text` / `blob` / `arrayBuffer` / `formData` / `stream` (raw `ReadableStream` via `response.body`) / `auto` (content-type sniff, the default); the per-call `RequestOptions` (the trailing `init` arg) carries `parseAs?: ParseAs` — a runtime escape hatch that overrides the inferred decode kind (static return type unchanged) — alongside the `retry` override. +`parse` decodes a success body into the requested kind — `json` / `text` / `blob` / `arrayBuffer` / `formData` / `stream` (raw `ReadableStream` via `response.body`) / `auto` (content-type sniff, the default); the per-call `RequestOptions` (the trailing `init` arg) carries `parseAs?: ParseAs` — a runtime escape hatch that overrides the inferred decode kind (static return type unchanged) — alongside the `retry` override and `envelope?: true` (throw mode only: return `{ data, headers, response }` with camelCase coerced declared success-response headers; use `response.headers` for undocumented ones; ignored in result mode). **Error mode** lives in the instance config (fixed at generate time; `configure()` ignores attempts to flip it): throw mode throws `ApiError` on non-2xx, result mode returns the discriminated `Result`. For **SSE** operations `runtime/sse.ts` provides `sse` — an `async function*` that parses `text/event-stream` frames into `ServerSentEvent` (`{ event?, data, id?, retry? }`) and auto-reconnects (resuming with `Last-Event-ID`; backoff = server `retry:` → `reconnectDelay` → 1000ms, exponential + jitter, 30s cap). `SseOptions` (`RequestInit & { reconnect?; reconnectDelay? }`) is the per-call init; aborting via `AbortSignal` or `break`ing the loop completes the iterator cleanly (no throw). diff --git a/packages/client-generator/src/__tests__/entry-weight.test.ts b/packages/client-generator/src/__tests__/entry-weight.test.ts index 2f0280a5d8..c2d257763f 100644 --- a/packages/client-generator/src/__tests__/entry-weight.test.ts +++ b/packages/client-generator/src/__tests__/entry-weight.test.ts @@ -38,4 +38,11 @@ describe('package root entry (lib/index.js)', () => { ); expect(outsideRuntime).toEqual([]); }); + + it('re-exports Envelope and EnvelopeResult for package-mode clients', () => { + // Package-mode sugar imports EnvelopeResult; the generated file re-exports Envelope. + const dts = readFileSync(join(libDir, 'index.d.ts'), 'utf-8'); + expect(dts).toMatch(/\bEnvelope\b/); + expect(dts).toMatch(/\bEnvelopeResult\b/); + }); }); diff --git a/packages/client-generator/src/__tests__/index.test.ts b/packages/client-generator/src/__tests__/index.test.ts index aaccbf9729..1ab6e8dd65 100644 --- a/packages/client-generator/src/__tests__/index.test.ts +++ b/packages/client-generator/src/__tests__/index.test.ts @@ -162,7 +162,7 @@ describe('generateClient — end-to-end orchestration', () => { const contents = await readFile(output, 'utf-8'); expect(contents).toContain( - 'export const ping = (init: RequestOptions = {}) => client.ping({}, init);' + 'export const ping = (init?: I): Promise, I>>' ); expect(contents).toContain('// Generated by @redocly/client-generator'); // bytes should match what we wrote. @@ -295,7 +295,9 @@ describe('generateClient — end-to-end orchestration', () => { expect(result.bytes).toBeGreaterThan(0); const contents = await readFile(output, 'utf-8'); - expect(contents).toContain('export const listItems = ('); + expect(contents).toContain( + 'export const listItems = (' + ); expect(contents).toContain('export type Item'); expect(contents).toContain('serverUrl: "https://api.example.com/v1"'); }); diff --git a/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap b/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap index 95463208f5..420750eb85 100644 --- a/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap +++ b/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap @@ -8,7 +8,7 @@ exports[`emitClientSingleFile (package arm) > matches the golden output for a sm * T (v1.0.0) */ -import { createClient, type OperationDescriptor, type RequestOptions, type SseOptions } from '@redocly/client-generator'; +import { createClient, type EnvelopeResult, type OperationDescriptor, type RequestOptions, type SseOptions } from '@redocly/client-generator'; export type Order = { id: string; @@ -72,13 +72,13 @@ export const client = createClient(orderId: string, params: { expand?: string; -} = {}, init: RequestOptions = {}) => client.getOrder({ orderId, params }, init); +} = {}, init?: I): Promise, I>> => client.getOrder({ orderId, params }, init) as Promise, I>>; export const streamEvents = (init: SseOptions = {}) => client.streamEvents({}, init); export { ApiError, createClient, defaultRetryOn, TimeoutError } from '@redocly/client-generator'; -export type { ClientConfig, Middleware, RequestOptions, ServerSentEvent, SseOptions } from '@redocly/client-generator'; +export type { ClientConfig, Envelope, Middleware, RequestOptions, ServerSentEvent, SseOptions } from '@redocly/client-generator'; " `; @@ -90,7 +90,7 @@ exports[`emitClientSingleFile — pagination > matches the golden output for a p * T (v1.0.0) */ -import { createClient, type OperationDescriptor, type RequestOptions } from '@redocly/client-generator'; +import { createClient, type EnvelopeResult, type OperationDescriptor, type RequestOptions } from '@redocly/client-generator'; export type Order = {}; @@ -169,16 +169,16 @@ export type OperationTag = Extract<(typeof OPERATIONS)[keyof typeof OPERATIONS], export const client = createClient(OPERATIONS, { serverUrl: "https://api.example.com", clientHeader: "redocly-client-generator" }); export const { configure, use } = client; -export const listOrders = Object.assign((params: { +export const listOrders = Object.assign((params: { cursor?: string; limit?: string; -} = {}, init: RequestOptions = {}) => client.listOrders({ params }, init), { pages: client.listOrders.pages, items: client.listOrders.items }); -export const getOrder = (orderId: string, params: { +} = {}, init?: I): Promise, I>> => client.listOrders({ params }, init) as Promise, I>>, { pages: client.listOrders.pages, items: client.listOrders.items }); +export const getOrder = (orderId: string, params: { expand?: string; -} = {}, init: RequestOptions = {}) => client.getOrder({ orderId, params }, init); +} = {}, init?: I): Promise, I>> => client.getOrder({ orderId, params }, init) as Promise, I>>; export { ApiError, createClient, defaultRetryOn, TimeoutError } from '@redocly/client-generator'; -export type { ClientConfig, Middleware, RequestOptions } from '@redocly/client-generator'; +export type { ClientConfig, Envelope, Middleware, RequestOptions } from '@redocly/client-generator'; " `; @@ -239,6 +239,7 @@ export type Ops = { params?: ListOrdersParams; }; result: Result; + mode: "result"; item: Order; page: ListOrdersResult; }; @@ -248,6 +249,7 @@ export type Ops = { params?: GetOrderParams; }; result: Result; + mode: "result"; }; }; @@ -281,6 +283,6 @@ export const getOrder = (orderId: string, params: { } = {}, init: RequestOptions = {}) => client.getOrder({ orderId, params }, init); export { ApiError, createClient, defaultRetryOn, TimeoutError } from '@redocly/client-generator'; -export type { ClientConfig, Middleware, RequestOptions, Result } from '@redocly/client-generator'; +export type { ClientConfig, Envelope, Middleware, RequestOptions, Result } from '@redocly/client-generator'; " `; diff --git a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts b/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts index 7f6c560883..7964d0bd5b 100644 --- a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts +++ b/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts @@ -91,7 +91,7 @@ describe('emitClientSingleFile (package arm)', () => { it('imports from the package instead of inlining the runtime template', () => { expect(output).toContain( - "import { createClient, type OperationDescriptor, type RequestOptions, type SseOptions, type TokenProvider } from '@redocly/client-generator';" + "import { createClient, type EnvelopeResult, type OperationDescriptor, type RequestOptions, type SseOptions, type TokenProvider } from '@redocly/client-generator';" ); expect(output).not.toContain('__send'); expect(output).not.toContain('__buildUrl'); @@ -160,14 +160,16 @@ describe('emitClientSingleFile (package arm)', () => { }); it('emits flat sugar one-liners forwarding to the grouped client methods', () => { - // Same positional signature style as inline flat mode (`renderArgList`): inline - // param object types with `= {}` defaults, trailing `init: … = {}`. - expect(output).toContain('export const getOrder = (orderId: string, params: {'); - expect(output).toContain('=> client.getOrder({ orderId, params }, init);'); + // Throw-mode flat sugar is generic over `init` so `{ envelope: true }` narrows. expect(output).toContain( - 'export const createPet = (body: Pet, init: RequestOptions = {}) => client.createPet({ body }, init);' + 'export const getOrder = (orderId: string, params: {' ); - // SSE sugar takes SseOptions and returns the generator directly. + expect(output).toContain('=> client.getOrder({ orderId, params }, init) as Promise<'); + expect(output).toContain( + 'export const createPet = (body: Pet, init?: I): Promise, I>'); + // SSE sugar takes SseOptions and returns the generator directly (no envelope). expect(output).toContain( 'export const streamEvents = (init: SseOptions = {}) => client.streamEvents({}, init);' ); @@ -177,8 +179,9 @@ describe('emitClientSingleFile (package arm)', () => { expect(output).toContain('configure_2: {'); expect(output).toContain('id: "configure"'); // descriptor id stays the spec operationId expect(output).toContain( - 'export const configure_2 = (init: RequestOptions = {}) => client.configure_2({}, init);' + 'export const configure_2 = (init?: I): Promise client.configure_2({}, init) as Promise<'); }); it('re-exports the public surface', () => { @@ -186,7 +189,7 @@ describe('emitClientSingleFile (package arm)', () => { "export { ApiError, createClient, defaultRetryOn, TimeoutError } from '@redocly/client-generator';" ); expect(output).toContain( - "export type { ClientConfig, Middleware, RequestOptions, ServerSentEvent, SseOptions } from '@redocly/client-generator';" + "export type { ClientConfig, Envelope, Middleware, RequestOptions, ServerSentEvent, SseOptions } from '@redocly/client-generator';" ); }); @@ -202,8 +205,9 @@ describe('emitClientSingleFile (package arm)', () => { // No options at all — the emitter's own defaults apply. const out = emit(model); expect(out).toContain( - 'export const getPet = (pet_id: string, init: RequestOptions = {}) => client.getPet({ "pet-id": pet_id }, init);' + 'export const getPet = (pet_id: string, init?: I): Promise client.getPet({ "pet-id": pet_id }, init) as Promise<'); expect(out).toContain('"pet-id": string;'); // Ops args + Variables alias, wire-keyed }); @@ -218,7 +222,7 @@ describe('emitClientSingleFile (package arm)', () => { ]); // `a-b` sanitizes to `a_b`, so the literal `a_b` param is deduped to `a_b_2` — // but both forward under their wire names. - expect(emit(model)).toContain('client.compare({ "a-b": a_b, a_b: a_b_2 }, init);'); + expect(emit(model)).toContain('client.compare({ "a-b": a_b, a_b: a_b_2 }, init) as Promise<'); }); it('layers a baked setup OVER the spec defaults and imports the contract types', () => { @@ -227,7 +231,7 @@ describe('emitClientSingleFile (package arm)', () => { setup: '{ config: { retry: { retries: 2 } } }', }); expect(out).toContain( - "import { createClient, mergeSetup, type ClientConfig, type Middleware, type OperationDescriptor, type RequestOptions } from '@redocly/client-generator';" + "import { createClient, mergeSetup, type ClientConfig, type EnvelopeResult, type Middleware, type OperationDescriptor, type RequestOptions } from '@redocly/client-generator';" ); expect(out).toContain( 'const __redoclySetup: { config?: ClientConfig; middleware?: Middleware[] } = { config: { retry: { retries: 2 } } };' @@ -244,7 +248,7 @@ describe('emitClientSingleFile (package arm)', () => { // The SSE member stays unwrapped, and the re-export list still offers Result. expect(out).toContain('kind: "sse"'); expect(out).toContain( - "export type { ClientConfig, Middleware, RequestOptions, Result, ServerSentEvent, SseOptions } from '@redocly/client-generator';" + "export type { ClientConfig, Envelope, Middleware, RequestOptions, Result, ServerSentEvent, SseOptions } from '@redocly/client-generator';" ); }); @@ -339,7 +343,7 @@ describe('emitClientSingleFile (package arm)', () => { }), ]) ); - expect(out).toContain('=> client.ping({ headers }, init);'); + expect(out).toContain('=> client.ping({ headers }, init) as Promise<'); }); it('matches the golden output for a small model', () => { @@ -484,12 +488,15 @@ describe('emitClientSingleFile — pagination', () => { it('wraps the flat sugar in Object.assign, preserving .pages/.items', () => { const out = emit(PAGINATED, { pagination: config }); - expect(out).toContain('export const listOrders = Object.assign((params: {'); expect(out).toContain( - '} = {}, init: RequestOptions = {}) => client.listOrders({ params }, init), { pages: client.listOrders.pages, items: client.listOrders.items });' + 'export const listOrders = Object.assign((params: {' ); + expect(out).toContain('=> client.listOrders({ params }, init) as Promise<'); + expect(out).toContain('{ pages: client.listOrders.pages, items: client.listOrders.items });'); // Non-paginated siblings keep the plain arrow. - expect(out).toContain('export const getOrder = (orderId: string, params: {'); + expect(out).toContain( + 'export const getOrder = (orderId: string, params: {' + ); expect(out).not.toContain('Object.assign((orderId'); }); diff --git a/packages/client-generator/src/emitters/__tests__/descriptor.test.ts b/packages/client-generator/src/emitters/__tests__/descriptor.test.ts index 6568550376..4480bb64e5 100644 --- a/packages/client-generator/src/emitters/__tests__/descriptor.test.ts +++ b/packages/client-generator/src/emitters/__tests__/descriptor.test.ts @@ -7,7 +7,7 @@ import { descriptorStatements, opsInterfaceStatements, packageIdents } from '../ import type { EmitContext } from '../operations.js'; import type { ModelPagination } from '../pagination.js'; import { printStatements } from '../ts.js'; -import { apiModel, modelWith, operation, param } from './fixtures.js'; +import { apiModel, modelWith, operation, param, response } from './fixtures.js'; function emitDescriptors(model: ApiModel): string { return printStatements(descriptorStatements(model, packageIdents(model), 'string')); @@ -344,6 +344,53 @@ describe('descriptorStatements', () => { // Non-paginated entries carry no pagination field. expect(out).toContain('ping: { id: "ping", method: "GET", path: "/ping" }'); }); + + it('emits responseHeaders coerce specs from declared success-response headers', () => { + const out = emitDescriptors( + modelWith([ + operation({ + name: 'listCustomers', + path: '/customers', + successResponses: [ + response({ + schema: { kind: 'array', items: { kind: 'ref', name: 'Customer' } }, + }), + ], + successResponseHeaders: [ + { + name: 'pagination-total', + schema: { kind: 'scalar', scalar: 'integer' }, + required: true, + }, + { name: 'link', schema: { kind: 'scalar', scalar: 'string' } }, + ], + }), + ]) + ); + expect(out).toContain( + 'responseHeaders: [{ name: "pagination-total", key: "paginationTotal", type: "number" }, { name: "link", key: "link", type: "string" }]' + ); + }); + + it('emits safe unique response-header descriptor keys', () => { + const out = emitDescriptors( + modelWith([ + operation({ + name: 'listCustomers', + successResponses: [response()], + successResponseHeaders: [ + { name: '3d-secure', schema: { kind: 'scalar', scalar: 'boolean' } }, + { name: 'x-foo', schema: { kind: 'scalar', scalar: 'integer' } }, + { name: 'x_foo', schema: { kind: 'scalar', scalar: 'string' } }, + ], + }), + ]) + ); + + expect(out).toContain( + 'responseHeaders: [{ name: "3d-secure", key: "_3dSecure", type: "boolean" }, { name: "x-foo", key: "xFoo", type: "number" }, { name: "x_foo", key: "xFoo_2", type: "string" }]' + ); + }); }); describe('opsInterfaceStatements', () => { @@ -569,7 +616,7 @@ describe('opsInterfaceStatements', () => { // Result mode: `result` is the envelope, so `page` carries the raw page for `.pages()`. const out = emitOps(modelWith([listOrders]), { pagination, errorMode: 'result' }); expect(out).toMatch( - /listOrders: \{\n {8}args: \{\n {12}params\?: ListOrdersParams;\n {8}\};\n {8}result: Result;\n {8}item: Order;\n {8}page: ListOrdersResult;\n {4}\};/ + /listOrders: \{\n {8}args: \{\n {12}params\?: ListOrdersParams;\n {8}\};\n {8}result: Result;\n {8}mode: "result";\n {8}item: Order;\n {8}page: ListOrdersResult;\n {4}\};/ ); // Throw mode emits no page member — `result` already IS the raw page. expect(emitOps(modelWith([listOrders]), { pagination })).not.toContain('page:'); @@ -607,4 +654,57 @@ describe('opsInterfaceStatements', () => { const out = emitOps(modelWith([listOrders]), { pagination, dateType: 'Date' }); expect(out).toContain('item: Date;'); }); + + it('adds a headers member from declared success-response headers', () => { + const out = emitOps( + modelWith([ + operation({ + name: 'listCustomers', + path: '/customers', + successResponses: [ + response({ + schema: { kind: 'array', items: { kind: 'ref', name: 'Customer' } }, + }), + ], + successResponseHeaders: [ + { name: 'pagination-total', schema: { kind: 'scalar', scalar: 'integer' } }, + ], + }), + ]) + ); + expect(out).toContain('headers: {\n paginationTotal?: number;\n };'); + }); + + it('emits safe unique keys, requiredness, and only runtime-supported header types', () => { + const out = emitOps( + modelWith([ + operation({ + name: 'listCustomers', + path: '/customers', + successResponses: [response()], + successResponseHeaders: [ + { + name: '3d-secure', + schema: { kind: 'scalar', scalar: 'boolean' }, + required: true, + }, + { name: 'x-foo', schema: { kind: 'scalar', scalar: 'integer' } }, + { name: 'x_foo', schema: { kind: 'scalar', scalar: 'string' } }, + { + name: 'x-ids', + schema: { + kind: 'array', + items: { kind: 'scalar', scalar: 'integer' }, + }, + }, + ], + }), + ]) + ); + + expect(out).toContain('_3dSecure: boolean;'); + expect(out).toContain('xFoo?: number;'); + expect(out).toContain('xFoo_2?: string;'); + expect(out).toContain('xIds?: string;'); + }); }); diff --git a/packages/client-generator/src/emitters/__tests__/operations.test.ts b/packages/client-generator/src/emitters/__tests__/operations.test.ts index bfaf613152..14e94c7323 100644 --- a/packages/client-generator/src/emitters/__tests__/operations.test.ts +++ b/packages/client-generator/src/emitters/__tests__/operations.test.ts @@ -17,10 +17,23 @@ function emitResult(op: Partial, schemas: string[] = []): string ); } +/** Throw-mode flat sugar is generic over `init` so `{ envelope: true }` narrows the return. */ +function envelopeFlatSugar( + name: string, + argsBeforeInit: string, + callArgs: string, + resultType: string, + headersType = 'Record' +): string { + const params = argsBeforeInit ? `${argsBeforeInit}, init?: I` : 'init?: I'; + const promise = `Promise>`; + return `export const ${name} = (${params}): ${promise} => client.${name}(${callArgs}, init) as ${promise};`; +} + describe('flat sugar — argument-list permutations (renderArgList)', () => { it('renders an operation with no inputs: only the trailing init, forwarding empty args', () => { const out = emitWithOp({}); - expect(out).toContain('export const op = (init: RequestOptions = {}) => client.op({}, init);'); + expect(out).toContain(envelopeFlatSugar('op', '', '{}', 'OpResult')); }); it('orders path params by their position in the URL template, not in pathParams[]', () => { @@ -33,7 +46,12 @@ describe('flat sugar — argument-list permutations (renderArgList)', () => { ], }); expect(out).toContain( - 'export const getNested = (first: string, second: number, init: RequestOptions = {}) => client.getNested({ first, second }, init);' + envelopeFlatSugar( + 'getNested', + 'first: string, second: number', + '{ first, second }', + 'GetNestedResult' + ) ); }); @@ -49,7 +67,7 @@ describe('flat sugar — argument-list permutations (renderArgList)', () => { pathParams: [param('pet-id', 'path', true)], }); expect(out).toContain( - 'export const getPet = (pet_id: string, init: RequestOptions = {}) => client.getPet({ "pet-id": pet_id }, init);' + envelopeFlatSugar('getPet', 'pet_id: string', '{ "pet-id": pet_id }', 'GetPetResult') ); }); @@ -133,7 +151,7 @@ describe('flat sugar — argument-list permutations (renderArgList)', () => { headerParams: [param('X-Api-Version', 'header', true)], }); expect(out).toMatch(/headers: \{\n {4}"X-Api-Version": string;\n\}, init/); - expect(out).toContain('=> client.getThing({ headers }, init);'); + expect(out).toContain('=> client.getThing({ headers }, init) as Promise<'); }); it('defaults the `headers` slot to `= {}` when all header params are optional', () => { @@ -183,9 +201,7 @@ describe('flat sugar — argument-list permutations (renderArgList)', () => { expect(out).toContain( 'export const streamMessages = (init: SseOptions = {}) => client.streamMessages({}, init);' ); - expect(out).toContain( - 'export const listThings = (init: RequestOptions = {}) => client.listThings({}, init);' - ); + expect(out).toContain(envelopeFlatSugar('listThings', '', '{}', 'ListThingsResult')); }); }); diff --git a/packages/client-generator/src/emitters/__tests__/pagination.test.ts b/packages/client-generator/src/emitters/__tests__/pagination.test.ts index cbb1a2cc1d..fbb6aa7100 100644 --- a/packages/client-generator/src/emitters/__tests__/pagination.test.ts +++ b/packages/client-generator/src/emitters/__tests__/pagination.test.ts @@ -265,8 +265,11 @@ describe('resolveOperationPagination — sources and precedence', () => { function linkOp(extra: Partial = {}): OperationModel { return listOrders({ successResponses: [ - response({ schema: { kind: 'ref', name: 'OrderPage' }, headers: ['link'] }), + response({ + schema: { kind: 'ref', name: 'OrderPage' }, + }), ], + successResponseHeaders: [{ name: 'link', schema: { kind: 'scalar', scalar: 'string' } }], ...extra, }); } diff --git a/packages/client-generator/src/emitters/__tests__/swr.test.ts b/packages/client-generator/src/emitters/__tests__/swr.test.ts index 0004e0be7e..addf0b6031 100644 --- a/packages/client-generator/src/emitters/__tests__/swr.test.ts +++ b/packages/client-generator/src/emitters/__tests__/swr.test.ts @@ -86,9 +86,11 @@ describe('renderSwrModule', () => { 'export const getPetKey = (vars: GetPetVariables) => ["getPet", vars] as const;' ); expect(out).toContain( - 'export function useGetPet(vars: GetPetVariables, init?: RequestOptions) {' + 'export function useGetPet(vars: GetPetVariables, init?: Omit) {' + ); + expect(out).toContain( + 'return useSWR(getPetKey(vars), () => getPet(vars, { ...init, envelope: undefined }));' ); - expect(out).toContain('return useSWR(getPetKey(vars), () => getPet(vars, init));'); }); }); @@ -96,8 +98,20 @@ describe('renderSwrModule', () => { it('drops the vars param; key takes no args', () => { const out = render([{ name: 'listPets', method: 'get', path: '/pets' }]); expect(out).toContain('export const listPetsKey = () => ["listPets"] as const;'); - expect(out).toContain('export function useListPets(init?: RequestOptions) {'); - expect(out).toContain('return useSWR(listPetsKey(), () => listPets(init));'); + expect(out).toContain( + 'export function useListPets(init?: Omit) {' + ); + // Grouped signature is `(args?, init?)` — the init must not land in the args slot. + expect(out).toContain( + 'return useSWR(listPetsKey(), () => listPets({}, { ...init, envelope: undefined }));' + ); + }); + + it('flat style: the no-input sugar takes the init directly', () => { + const out = render([{ name: 'listPets', method: 'get', path: '/pets' }], 'flat'); + expect(out).toContain( + 'return useSWR(listPetsKey(), () => listPets({ ...init, envelope: undefined }));' + ); }); }); @@ -140,7 +154,9 @@ describe('renderSwrModule', () => { ], 'flat' ); - expect(out).toContain('() => getPet(vars.petId, vars.params, init)'); + expect(out).toContain( + '() => getPet(vars.petId, vars.params, { ...init, envelope: undefined })' + ); }); it('mutation: spreads arg. (URL-template order), then params, body, headers', () => { diff --git a/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts b/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts index e073c6fcd1..ae56f1cc15 100644 --- a/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts +++ b/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts @@ -94,10 +94,12 @@ describe('renderTanstackModule', () => { it('emits an Options factory whose queryFn forwards the abort signal to the client call', () => { const out = render([getOp]); expect(out).toContain( - 'getPetOptions: (vars: GetPetVariables, init?: RequestOptions) => queryOptions({' + 'getPetOptions: (vars: GetPetVariables, init?: Omit) => queryOptions({' ); expect(out).toContain('queryKey: getPetQueryKey(vars)'); - expect(out).toContain('queryFn: ({ signal }) => instance.getPet(vars, { ...init, signal })'); + expect(out).toContain( + 'queryFn: ({ signal }) => instance.getPet(vars, { ...init, signal, envelope: undefined })' + ); }); }); @@ -111,10 +113,10 @@ describe('renderTanstackModule', () => { it('emits a Mutation factory that accepts and forwards per-call init', () => { const out = render([postOp]); - expect(out).toContain('createPetMutation: (init?: RequestOptions) => ({'); + expect(out).toContain('createPetMutation: (init?: Omit) => ({'); expect(out).toContain('mutationKey: ["createPet"] as const'); expect(out).toContain( - 'mutationFn: (vars: CreatePetVariables) => instance.createPet(vars, init)' + 'mutationFn: (vars: CreatePetVariables) => instance.createPet(vars, { ...init, envelope: undefined })' ); }); }); @@ -223,11 +225,11 @@ describe('renderTanstackModule', () => { it('compiles a cursor rule into initialPageParam + getNextPageParam with a distinct key', () => { const out = render([listOp], { pagination: cursorRule }); expect(out).toContain( - 'listOrdersInfiniteOptions: (vars: ListOrdersVariables, init?: RequestOptions) => infiniteQueryOptions({' + 'listOrdersInfiniteOptions: (vars: ListOrdersVariables, init?: Omit) => infiniteQueryOptions({' ); expect(out).toContain('queryKey: [...listOrdersQueryKey(vars), "infinite"] as const'); expect(out).toContain( - 'queryFn: ({ pageParam, signal }) => instance.listOrders({ ...vars, params: { ...vars.params, after: pageParam } }, { ...init, signal })' + 'queryFn: ({ pageParam, signal }) => instance.listOrders({ ...vars, params: { ...vars.params, after: pageParam } }, { ...init, signal, envelope: undefined })' ); expect(out).toContain('initialPageParam: vars.params?.after'); expect(out).toContain('if (lastPage.page?.hasNextPage === false)'); @@ -306,7 +308,9 @@ describe('renderTanstackModule', () => { it('skips InfiniteOptions for link-style pagination (the next page lives in a header)', () => { const linkOp = { ...listOp, - successResponses: [{ ...listOp.successResponses[0], headers: ['link'] }], + successResponseHeaders: [ + { name: 'link', schema: { kind: 'scalar', scalar: 'string' } as const }, + ], }; const out = render([linkOp], { pagination: { style: 'link', items: '/items' } }); expect(out).toContain('listOrdersOptions'); @@ -326,15 +330,21 @@ describe('renderTanstackModule', () => { it('query: init-only Options, queryKey without vars, empty args object to the client', () => { const out = render([{ name: 'listPets', method: 'get', path: '/pets' }]); expect(out).toContain('export const listPetsQueryKey = () => ["listPets"] as const;'); - expect(out).toContain('listPetsOptions: (init?: RequestOptions) => queryOptions({'); + expect(out).toContain( + 'listPetsOptions: (init?: Omit) => queryOptions({' + ); expect(out).toContain('queryKey: listPetsQueryKey()'); - expect(out).toContain('queryFn: ({ signal }) => instance.listPets({}, { ...init, signal })'); + expect(out).toContain( + 'queryFn: ({ signal }) => instance.listPets({}, { ...init, signal, envelope: undefined })' + ); }); it('mutation: mutationFn takes no vars, passes an empty args object plus init', () => { const out = render([{ name: 'ping', method: 'post', path: '/ping' }]); - expect(out).toContain('pingMutation: (init?: RequestOptions) => ({'); - expect(out).toContain('mutationFn: () => instance.ping({}, init)'); + expect(out).toContain('pingMutation: (init?: Omit) => ({'); + expect(out).toContain( + 'mutationFn: () => instance.ping({}, { ...init, envelope: undefined })' + ); }); }); diff --git a/packages/client-generator/src/emitters/client-assembly.ts b/packages/client-generator/src/emitters/client-assembly.ts index f5b23fbca1..9a9eaeb0d5 100644 --- a/packages/client-generator/src/emitters/client-assembly.ts +++ b/packages/client-generator/src/emitters/client-assembly.ts @@ -25,6 +25,7 @@ import { operationSignature } from './operation-signature.js'; import { computeResponse, errorTypeNodes, isTypedMultipart } from './operation-types.js'; import { type EmitContext, renderArgList } from './operations.js'; import { resolveModelPagination } from './pagination.js'; +import { responseHeadersTypeLiteral } from './response-headers.js'; import { isSseOp } from './sse.js'; import { pascalCase } from './support.js'; import { @@ -34,6 +35,7 @@ import { printNodes, printStatements, ts, + typedArrow, } from './ts.js'; import { typeGuardStatements } from './type-guards.js'; import { typesStatements } from './types.js'; @@ -208,6 +210,8 @@ function importLine( 'OperationDescriptor', // Flat sugar signatures reference the per-call option types. ...(refs.hasFlatRegular ? ['RequestOptions'] : []), + // Flat throw-mode sugar return types vary with the inferred request-option type. + ...(refs.hasFlatRegular && ctx.errorMode !== 'result' ? ['EnvelopeResult'] : []), // `Ops` wraps results in `Result` in result mode — but only NON-SSE members // (an SSE-only spec would otherwise import it unused and fail noUnusedLocals). ...(ctx.errorMode === 'result' && refs.hasRegular ? ['Result'] : []), @@ -324,6 +328,8 @@ function sugarStatements( * collide with the slot keys — a spec-acknowledged runtime-contract limitation. * A paginated operation's arrow is wrapped in `Object.assign(…, { pages, items })` * so the flat sugar preserves the method-attached iterators. + * Throw-mode (non-SSE) arrows are generic over `init` so `{ envelope: true }` narrows + * the return type to `Envelope<…>` (plain `RequestOptions` would collapse the overload). */ function flatSugarStatement(op: OperationModel, ident: string, ctx: EmitContext): ts.Statement { const { pathParams } = operationSignature(op); @@ -354,7 +360,10 @@ function flatSugarStatement(op: OperationModel, ident: string, ctx: EmitContext) undefined, [factory.createObjectLiteralExpression(props, false), factory.createIdentifier('init')] ); - const fn = arrow(params, call); + const fn = + ctx.errorMode !== 'result' && !isSseOp(op) + ? envelopeAwareFlatArrow(op, params, call, ctx) + : arrow(params, call); if (!ctx.pagination?.has(op.name)) return exportConstStatement(ident, fn); const methodMember = (name: string) => factory.createPropertyAssignment( @@ -380,10 +389,78 @@ function flatSugarStatement(op: OperationModel, ident: string, ctx: EmitContext) ); } +/** + * `(…, init?: I) => + * Promise>` + */ +function envelopeAwareFlatArrow( + op: OperationModel, + params: ts.ParameterDeclaration[], + call: ts.Expression, + ctx: EmitContext +): ts.ArrowFunction { + // `renderArgList` always appends `init` last — retype it as optional generic `I` + // (no default: `init?: I = {}` is invalid, and `init: I = {}` fails under strict + // generic checks). Cast the call's Promise so the conditional return type sticks. + const initTyped = [ + ...params.slice(0, -1), + factory.createParameterDeclaration( + undefined, + undefined, + 'init', + factory.createToken(ts.SyntaxKind.QuestionToken), + factory.createTypeReferenceNode('I') + ), + ]; + const resultType = flatResultType(op, ctx); + const headersType = flatHeadersType(op, ctx); + const returnType = factory.createTypeReferenceNode('Promise', [ + factory.createTypeReferenceNode('EnvelopeResult', [ + resultType, + headersType, + factory.createTypeReferenceNode('I'), + ]), + ]); + const typeParam = factory.createTypeParameterDeclaration( + undefined, + 'I', + factory.createUnionTypeNode([ + factory.createTypeReferenceNode('RequestOptions'), + factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword), + ]), + factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword) + ); + const castCall = factory.createAsExpression(call, returnType); + return typedArrow([typeParam], initTyped, returnType, castCall); +} + +function flatResultType(op: OperationModel, ctx: EmitContext): ts.TypeNode { + const { responseType } = computeResponse(op.successResponses, ctx.dateType); + const resultName = `${pascalCase(op.name)}Result`; + return ctx.schemaNames.has(resultName) + ? responseType + : factory.createTypeReferenceNode(resultName); +} + +function flatHeadersType(op: OperationModel, ctx: EmitContext): ts.TypeNode { + const headers = op.successResponseHeaders; + if (!headers || headers.length === 0) { + return factory.createTypeReferenceNode('Record', [ + factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), + factory.createKeywordTypeNode(ts.SyntaxKind.NeverKeyword), + ]); + } + const alias = `${pascalCase(op.name)}ResponseHeaders`; + return ctx.schemaNames.has(alias) + ? responseHeadersTypeLiteral(headers) + : factory.createTypeReferenceNode(alias); +} + /** Public type surface re-exported for single-import DX (plus the `ApiError` class). */ function reexportLines(ctx: EmitContext, hasSse: boolean): string { const types = [ 'ClientConfig', + 'Envelope', 'Middleware', 'RequestOptions', ...(ctx.errorMode === 'result' ? ['Result'] : []), diff --git a/packages/client-generator/src/emitters/descriptor.ts b/packages/client-generator/src/emitters/descriptor.ts index 4f9aa921d8..872d47e967 100644 --- a/packages/client-generator/src/emitters/descriptor.ts +++ b/packages/client-generator/src/emitters/descriptor.ts @@ -18,6 +18,7 @@ import { computeResponse, errorTypeNodes, isTypedMultipart } from './operation-t import type { EmitContext } from './operations.js'; import type { ModelPagination } from './pagination.js'; import { WIRING_NAMES } from './reserved-names.js'; +import { responseHeadersTypeLiteral, responseHeaderSpecs } from './response-headers.js'; import { isSseOp, sseDataKind, sseEventType } from './sse.js'; import { pascalCase } from './support.js'; import { jsdoc, literalExpression, parseStatements, ts } from './ts.js'; @@ -71,6 +72,7 @@ function descriptorValue( .filter((alternative) => alternative.length > 0); const sse = isSseOp(op); const responseKind = sse ? 'sse' : computeResponse(op.successResponses, dateType).responseKind; + const responseHeaders = responseHeaderSpecs(op.successResponseHeaders); return { // The spec's operationId, NOT the (possibly renamed) map key: `id` drives middleware // targeting (`ctx.operation.id`) and must match inline mode's `operationMetaExpr`. @@ -94,6 +96,7 @@ function descriptorValue( ...(security.length > 0 ? { security } : {}), // The resolved spec is already normalized with stable key order (see pagination.ts). ...(pagination?.has(op.name) ? { pagination: pagination.get(op.name)!.spec } : {}), + ...(responseHeaders === undefined ? {} : { responseHeaders }), }; } @@ -199,6 +202,28 @@ function opsMember(op: OperationModel, ident: string, ctx: EmitContext): ts.Prop factory.createPropertySignature(undefined, 'args', undefined, args), factory.createPropertySignature(undefined, 'result', undefined, resultType(op, ctx)), ]; + if (ctx.errorMode === 'result' && !isSseOp(op)) { + members.push( + factory.createPropertySignature( + undefined, + 'mode', + undefined, + factory.createLiteralTypeNode(factory.createStringLiteral('result')) + ) + ); + } + // Declared success-response headers type the throw-mode `{ envelope: true }` bag. + const responseHeaders = op.successResponseHeaders; + if (responseHeaders && responseHeaders.length > 0) { + members.push( + factory.createPropertySignature( + undefined, + 'headers', + undefined, + responseHeadersTypeLiteral(responseHeaders) + ) + ); + } // Paginated operations declare the page's element type — it drives the runtime's // `.pages()`/`.items()` members on the method (`Client` keys off `item`). const paginated = ctx.pagination?.get(op.name); diff --git a/packages/client-generator/src/emitters/operation-aliases.ts b/packages/client-generator/src/emitters/operation-aliases.ts index c8950dff91..8813e5b1c8 100644 --- a/packages/client-generator/src/emitters/operation-aliases.ts +++ b/packages/client-generator/src/emitters/operation-aliases.ts @@ -9,6 +9,7 @@ import { jsdocText } from './jsdoc.js'; import { operationSignature } from './operation-signature.js'; import { bodyTypeNode, paramsTypeLiteral, propertyKey } from './operation-types.js'; import type { EmitContext } from './operations.js'; +import { responseHeadersTypeLiteral } from './response-headers.js'; import { pascalCase } from './support.js'; import { jsdoc, ts } from './ts.js'; import { schemaToTypeNode } from './types.js'; @@ -82,6 +83,12 @@ export function renderOperationAliases( aliases.push(exportType(`${name}Headers`, paramsTypeLiteral(op.headerParams, dateType))); } + // Response headers (envelope) — distinct from request `Headers`. + const responseHeaders = op.successResponseHeaders; + if (responseHeaders && responseHeaders.length > 0 && !schemaNames.has(`${name}ResponseHeaders`)) { + aliases.push(exportType(`${name}ResponseHeaders`, responseHeadersTypeLiteral(responseHeaders))); + } + if (op.cookieParams.length > 0 && !schemaNames.has(`${name}Cookies`)) { aliases.push(exportType(`${name}Cookies`, paramsTypeLiteral(op.cookieParams, dateType))); } diff --git a/packages/client-generator/src/emitters/pagination.ts b/packages/client-generator/src/emitters/pagination.ts index 61b079ead2..41ecc8553c 100644 --- a/packages/client-generator/src/emitters/pagination.ts +++ b/packages/client-generator/src/emitters/pagination.ts @@ -172,7 +172,8 @@ function applyRule( // only to operations that document it; an explicit rule applies regardless (a spec // often under-documents headers) but says so, since the runtime then depends on an // undocumented behavior. - const documentsLink = page.headers?.includes('link') === true; + const documentsLink = + op.successResponseHeaders?.some((header) => header.name === 'link') === true; if (!documentsLink && !explicit) return {}; if (!documentsLink) { logger.warn( diff --git a/packages/client-generator/src/emitters/reserved-names.ts b/packages/client-generator/src/emitters/reserved-names.ts index ca6364f42f..d2d6f9a983 100644 --- a/packages/client-generator/src/emitters/reserved-names.ts +++ b/packages/client-generator/src/emitters/reserved-names.ts @@ -34,6 +34,8 @@ export const WIRING_NAMES = [ 'OperationDescriptor', 'ServerSentEvent', 'Result', + 'Envelope', + 'EnvelopeResult', 'TokenProvider', '__redoclySetup', ]; diff --git a/packages/client-generator/src/emitters/response-headers.ts b/packages/client-generator/src/emitters/response-headers.ts new file mode 100644 index 0000000000..b932e49e06 --- /dev/null +++ b/packages/client-generator/src/emitters/response-headers.ts @@ -0,0 +1,74 @@ +// Success-response header helpers: descriptor parse hints + Ops / alias type shapes +// for throw-mode `{ envelope: true }`. + +import type { ResponseHeaderModel, SchemaModel } from '../intermediate-representation/model.js'; +import type { ResponseHeaderSpec } from '../runtime/types.js'; +import { uniqueIdent } from './identifier.js'; +import { headerPropertyKey } from './support.js'; +import { ts } from './ts.js'; + +const { factory } = ts; + +type PlannedResponseHeader = ResponseHeaderModel & { + key: string; + type: ResponseHeaderSpec['type']; +}; + +/** Runtime coerce hint from a header schema (complex schemas fall back to string). */ +export function headerParseType(schema: SchemaModel): ResponseHeaderSpec['type'] { + if (schema.kind === 'scalar') { + if (schema.scalar === 'integer' || schema.scalar === 'number') return 'number'; + if (schema.scalar === 'boolean') return 'boolean'; + } + if (schema.kind === 'literal') { + if (typeof schema.value === 'number') return 'number'; + if (typeof schema.value === 'boolean') return 'boolean'; + } + if (schema.kind === 'enum') { + if (schema.scalar === 'integer' || schema.scalar === 'number') return 'number'; + if (schema.scalar === 'boolean') return 'boolean'; + } + return 'string'; +} + +/** Descriptor `responseHeaders` entries from the success response's declared headers. */ +export function responseHeaderSpecs( + headers: ResponseHeaderModel[] | undefined +): ResponseHeaderSpec[] | undefined { + const planned = planResponseHeaders(headers); + if (planned.length === 0) return undefined; + return planned.map((header) => ({ + name: header.name, + key: header.key, + type: header.type, + })); +} + +/** Type literal for Ops.`headers` / `ResponseHeaders`. */ +export function responseHeadersTypeLiteral(headers: ResponseHeaderModel[]): ts.TypeNode { + return factory.createTypeLiteralNode( + planResponseHeaders(headers).map((header) => { + return factory.createPropertySignature( + undefined, + factory.createIdentifier(header.key), + header.required === true ? undefined : factory.createToken(ts.SyntaxKind.QuestionToken), + headerTypeNode(header.type) + ); + }) + ); +} + +function planResponseHeaders(headers: ResponseHeaderModel[] | undefined): PlannedResponseHeader[] { + const used = new Set(); + return (headers ?? []).map((header) => ({ + ...header, + key: uniqueIdent(headerPropertyKey(header.name), used), + type: headerParseType(header.schema), + })); +} + +function headerTypeNode(type: ResponseHeaderSpec['type']): ts.TypeNode { + if (type === 'number') return factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword); + if (type === 'boolean') return factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword); + return factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); +} diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index b6432725e4..521964d845 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -1,7 +1,7 @@ // GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. export const RUNTIME_SOURCES = { 'types.ts': - "/**\n * The public type surface of the client runtime — `@redocly/client-generator`'s\n * app-facing runtime module. Pure types, no runtime code (excluded from coverage).\n * The generator emits `OPERATIONS` literals typed\n * `satisfies Record` against this module, so an\n * incompatible runtime/generated pair fails the consumer's build (the semver skew guard).\n */\n\n/** How one operation parameter is sent: its location plus OpenAPI query-serialization hints. */\nexport type ParamSpec = {\n name: string;\n in: 'path' | 'query' | 'header' | 'cookie';\n style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject';\n explode?: boolean;\n allowReserved?: boolean;\n};\n\n/** One security scheme, denormalized onto the operation (`scheme` names the spec's scheme). */\nexport type SecuritySpec =\n | { scheme: string; kind: 'bearer' | 'basic' }\n | { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' };\n\n/**\n * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members).\n * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value.\n */\nexport type PaginationSpec =\n | {\n style: 'cursor';\n /** The query param the iterator advances with the response's cursor. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the next cursor in the page. */\n nextCursor: string;\n /** Optional pointer to a boolean \"more pages\" flag — `false` stops iteration. */\n hasMore?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n style: 'offset' | 'page';\n /** The numeric query param the iterator advances. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n /** RFC 8288: follow the response's `Link` header `rel=\"next\"`; stop when absent. */\n style: 'link';\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n };\n\n/** The frozen data contract between generated code and the runtime: one operation's wire shape. */\nexport type OperationDescriptor = {\n id: string;\n method: string;\n path: string;\n tags?: readonly string[];\n params?: readonly ParamSpec[];\n /** `multipart: true` marks a typed object body serialized to FormData by the runtime. */\n body?: { contentType: string; multipart?: boolean };\n /** Defaults to `'json'` (content-type negotiation on parse). */\n responseKind?: 'json' | 'text' | 'blob' | 'void' | 'sse';\n sseDataKind?: 'json' | 'text';\n /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */\n security?: readonly (readonly SecuritySpec[])[];\n pagination?: PaginationSpec;\n};\n\n/** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */\nexport type QueryValue =\n | string\n | number\n | boolean\n | null\n | undefined\n | Array\n | Record;\n\n/** A credential: a literal, or a (possibly async) function resolved per request (refresh flows). */\nexport type TokenProvider = string | (() => string | Promise);\n\n/** Per-instance credentials, keyed by the scheme kinds the runtime can inject. */\nexport type AuthCredentials = {\n bearer?: TokenProvider;\n basic?: { username: string; password: string };\n apiKey?: Record;\n};\n\n/** Backoff shape: 'fixed' = constant delay; 'exponential' = doubling per attempt. */\nexport type RetryStrategy = 'fixed' | 'exponential';\n\n/**\n * The operation's identity, exposed to middleware for targeting (`ctx.operation`).\n * Generated clients instantiate the type parameters with the spec's literal unions\n * (`OperationId`/`OperationPath`/`OperationTag`) so a misspelled operation id in a\n * middleware comparison fails to compile; the string defaults keep every\n * spec-independent consumer (`runtime-contract.ts`, the runtime internals) working\n * with the base shape. `tags` stays mutable (`Tag[]`) so setup-contract types\n * (byte-locked to generated output) remain assignable through middleware callbacks.\n */\nexport type OperationContext<\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n> = { id: Id; path: Path; tags: Tag[] };\n\n/** The mutable request context threaded through the middleware chain. */\nexport type RequestContext = {\n url: string;\n method: string;\n headers: Record;\n body?: unknown;\n operation: Op;\n};\n\n/** The failed attempt handed to a custom `retryOn`: exactly one of `response`/`error` is set. */\nexport type RetryContext = {\n attempt: number;\n request: RequestContext;\n response?: Response;\n error?: unknown;\n};\n\n/** Opt-in retry policy; a per-call override merges field-by-field over the config policy. */\nexport type RetryConfig = {\n retries?: number;\n retryDelay?: number;\n retryStrategy?: RetryStrategy;\n jitter?: boolean;\n retryOn?: (ctx: RetryContext) => boolean | Promise;\n};\n\n/**\n * Structural stand-in for the runtime's ApiError so this module stays import-free\n * (pure types); the real `ApiError` class is assignable to it.\n */\nexport type ApiErrorLike = globalThis.Error & {\n url: string;\n status: number;\n statusText: string;\n body: unknown;\n};\n\n/** One interceptor: any subset of the three hooks. */\nexport type Middleware = {\n onRequest?: (ctx: RequestContext) => void | Promise;\n onResponse?: (\n response: Response,\n ctx: RequestContext\n ) => Response | void | Promise;\n /** Throw mode only: may map/replace the error. */\n // `globalThis.Error` so a spec schema named `Error` cannot shadow it in inline mode.\n onError?: (\n error: ApiErrorLike,\n ctx: RequestContext\n ) => globalThis.Error | Promise;\n};\n\n/** Client configuration: transport, defaults, retry policy, middleware, and credentials. */\nexport type ClientConfig = {\n serverUrl?: string;\n fetch?: typeof fetch;\n headers?:\n | Record\n | (() => Record | Promise>);\n retry?: RetryConfig;\n /** Milliseconds before a request attempt aborts (covers the body read too; each retry\n * attempt gets a fresh budget). Per-call `timeout` overrides it, `0` disables it.\n * SSE streams are long-lived by design and never inherit this value. */\n timeout?: number;\n /** Send an `Idempotency-Key` header on POST/PATCH (one stable key per logical call,\n * reused across retry attempts) — which also makes those retries safe under the\n * default retry policy. `true` generates a UUID per call; a function supplies the key. */\n idempotencyKey?: boolean | (() => string);\n /** Identifies this client to the API via an `X-Redocly-Client` header (the generator\n * bakes a default). Sent only OUTSIDE browsers — a custom header would force a CORS\n * preflight. Override with your own value, or `false` to disable. */\n clientHeader?: string | false;\n middleware?: Middleware[];\n auth?: AuthCredentials;\n /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */\n errorMode?: 'throw' | 'result';\n onRequest?: Middleware['onRequest'];\n onResponse?: Middleware['onResponse'];\n onError?: Middleware['onError'];\n};\n\n/** Response readers for the per-call `parseAs` override. */\nexport type ParseAs = 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'stream';\n\n/** Per-call options: standard `RequestInit` plus a retry override, a timeout override\n * (`0` disables the config default), and a forced reader. */\nexport type RequestOptions = RequestInit & {\n retry?: RetryConfig;\n timeout?: number;\n /** Per-call idempotency key: a literal key, `true` to generate one, `false` to skip. */\n idempotencyKey?: string | boolean | (() => string);\n parseAs?: ParseAs;\n};\n\n/** Per-call options for an SSE stream; reconnect defaults to true. */\nexport type SseOptions = RequestInit & { reconnect?: boolean; reconnectDelay?: number };\n\n/** A single decoded Server-Sent Event with its payload typed from the spec. */\nexport type ServerSentEvent = { event?: string; data: T; id?: string; retry?: number };\n\n/** Result-mode return shape: exactly one of `data`/`error` is set. */\nexport type Result =\n | { data: TData; error: undefined; response: Response }\n | { data: undefined; error: TError; response: Response };\n\n/**\n * The generated `Ops` type's shape: per-operation args/result, plus `kind: 'sse'` for\n * streams and, for paginated operations, `item` (the page's element type) and — on\n * result-mode clients only — `page` (the RAW page type `.pages()` yields, since\n * iteration unwraps the `Result` envelope the one-shot `result` carries).\n */\nexport type OpsShape = Record<\n string,\n { args: object; result: unknown; kind?: 'sse'; item?: unknown; page?: unknown }\n>;\n\n/** The always-present client members (assigned after the operation loop — they win collisions). */\nexport type ClientCore = {\n /** Merge into the config; note `middleware` REPLACES the chain (use `use()` to compose). */\n configure(config: ClientConfig): void;\n /** Append interceptors (composes with baked/publisher middleware). */\n use(...middleware: Middleware[]): void;\n auth: {\n bearer(token: TokenProvider): void;\n basic(username: string, password: string): void;\n apiKey(scheme: string, value: TokenProvider): void;\n };\n};\n\n/**\n * The standard TypeScript optionality probe: `{}` has no required members, so\n * `{} extends A` is true exactly when every member of `A` is optional.\n */\n// oxlint-disable-next-line typescript/no-empty-object-type\ntype NoRequiredKeys = {} extends A ? true : false;\n\n/**\n * The page type `.pages()` yields: the RAW page declared by `page` (the generator\n * writes it only on result-mode paginated entries, whose `result` is the envelope),\n * or the method's own `result` (throw mode — already the raw page).\n */\ntype PageOf = Entry extends { page: unknown }\n ? Entry['page']\n : Entry['result'];\n\n/**\n * The auto-pagination members intersected onto a paginated method — present exactly when\n * the Ops entry declares `item` (the generator writes it only for paginated operations).\n * Args optionality mirrors the method's own; `unknown` otherwise (identity under `&`).\n * Iteration is error-mode-agnostic: `.pages()`/`.items()` yield raw pages/items, and a\n * failed page aborts iteration by throwing `ApiError`, even on result-mode clients; the\n * `onError` middleware hook (throw-mode-only) is not invoked.\n */\ntype Paginated = 'item' extends keyof Entry\n ? NoRequiredKeys extends true\n ? {\n pages(args?: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args?: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : {\n pages(args: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : unknown;\n\n/**\n * The stable identity every client method carries: the SPEC operationId (also set as\n * `fn.name`, but `operationId` is the explicit, minification-proof form) — a robust\n * cache key for consumer wrappers (react-query keys and the like).\n */\nexport type OperationMethodIdentity = { readonly operationId: string };\n\n/** The typed instance client: one bound method per operation plus the core members. */\nexport type Client = {\n [K in keyof Ops]: Ops[K] extends { kind: 'sse' }\n ? (NoRequiredKeys extends true\n ? (\n args?: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>\n : (\n args: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>) &\n OperationMethodIdentity\n : (NoRequiredKeys extends true\n ? (args?: Ops[K]['args'], init?: RequestOptions) => Promise\n : (args: Ops[K]['args'], init?: RequestOptions) => Promise) &\n OperationMethodIdentity &\n Paginated;\n} & ClientCore;\n", + "/**\n * The public type surface of the client runtime — `@redocly/client-generator`'s\n * app-facing runtime module. Pure types, no runtime code (excluded from coverage).\n * The generator emits `OPERATIONS` literals typed\n * `satisfies Record` against this module, so an\n * incompatible runtime/generated pair fails the consumer's build (the semver skew guard).\n */\n\n/** How one operation parameter is sent: its location plus OpenAPI query-serialization hints. */\nexport type ParamSpec = {\n name: string;\n in: 'path' | 'query' | 'header' | 'cookie';\n style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject';\n explode?: boolean;\n allowReserved?: boolean;\n};\n\n/** One security scheme, denormalized onto the operation (`scheme` names the spec's scheme). */\nexport type SecuritySpec =\n | { scheme: string; kind: 'bearer' | 'basic' }\n | { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' };\n\n/**\n * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members).\n * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value.\n */\nexport type PaginationSpec =\n | {\n style: 'cursor';\n /** The query param the iterator advances with the response's cursor. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the next cursor in the page. */\n nextCursor: string;\n /** Optional pointer to a boolean \"more pages\" flag — `false` stops iteration. */\n hasMore?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n style: 'offset' | 'page';\n /** The numeric query param the iterator advances. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n /** RFC 8288: follow the response's `Link` header `rel=\"next\"`; stop when absent. */\n style: 'link';\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n };\n\n/** The frozen data contract between generated code and the runtime: one operation's wire shape. */\nexport type OperationDescriptor = {\n id: string;\n method: string;\n path: string;\n tags?: readonly string[];\n params?: readonly ParamSpec[];\n /** `multipart: true` marks a typed object body serialized to FormData by the runtime. */\n body?: { contentType: string; multipart?: boolean };\n /** Defaults to `'json'` (content-type negotiation on parse). */\n responseKind?: 'json' | 'text' | 'blob' | 'void' | 'sse';\n sseDataKind?: 'json' | 'text';\n /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */\n security?: readonly (readonly SecuritySpec[])[];\n pagination?: PaginationSpec;\n /**\n * Declared success-response headers for throw-mode `{ envelope: true }`.\n * `name` is the lowercased wire name; `key` is the camelCase envelope property.\n */\n responseHeaders?: readonly ResponseHeaderSpec[];\n};\n\n/** One declared response header the runtime coerces into the envelope `headers` object. */\nexport type ResponseHeaderSpec = {\n name: string;\n key: string;\n type: 'string' | 'number' | 'boolean';\n};\n\n/** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */\nexport type QueryValue =\n | string\n | number\n | boolean\n | null\n | undefined\n | Array\n | Record;\n\n/** A credential: a literal, or a (possibly async) function resolved per request (refresh flows). */\nexport type TokenProvider = string | (() => string | Promise);\n\n/** Per-instance credentials, keyed by the scheme kinds the runtime can inject. */\nexport type AuthCredentials = {\n bearer?: TokenProvider;\n basic?: { username: string; password: string };\n apiKey?: Record;\n};\n\n/** Backoff shape: 'fixed' = constant delay; 'exponential' = doubling per attempt. */\nexport type RetryStrategy = 'fixed' | 'exponential';\n\n/**\n * The operation's identity, exposed to middleware for targeting (`ctx.operation`).\n * Generated clients instantiate the type parameters with the spec's literal unions\n * (`OperationId`/`OperationPath`/`OperationTag`) so a misspelled operation id in a\n * middleware comparison fails to compile; the string defaults keep every\n * spec-independent consumer (`runtime-contract.ts`, the runtime internals) working\n * with the base shape. `tags` stays mutable (`Tag[]`) so setup-contract types\n * (byte-locked to generated output) remain assignable through middleware callbacks.\n */\nexport type OperationContext<\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n> = { id: Id; path: Path; tags: Tag[] };\n\n/** The mutable request context threaded through the middleware chain. */\nexport type RequestContext = {\n url: string;\n method: string;\n headers: Record;\n body?: unknown;\n operation: Op;\n};\n\n/** The failed attempt handed to a custom `retryOn`: exactly one of `response`/`error` is set. */\nexport type RetryContext = {\n attempt: number;\n request: RequestContext;\n response?: Response;\n error?: unknown;\n};\n\n/** Opt-in retry policy; a per-call override merges field-by-field over the config policy. */\nexport type RetryConfig = {\n retries?: number;\n retryDelay?: number;\n retryStrategy?: RetryStrategy;\n jitter?: boolean;\n retryOn?: (ctx: RetryContext) => boolean | Promise;\n};\n\n/**\n * Structural stand-in for the runtime's ApiError so this module stays import-free\n * (pure types); the real `ApiError` class is assignable to it.\n */\nexport type ApiErrorLike = globalThis.Error & {\n url: string;\n status: number;\n statusText: string;\n body: unknown;\n};\n\n/** One interceptor: any subset of the three hooks. */\nexport type Middleware = {\n onRequest?: (ctx: RequestContext) => void | Promise;\n onResponse?: (\n response: Response,\n ctx: RequestContext\n ) => Response | void | Promise;\n /** Throw mode only: may map/replace the error. */\n // `globalThis.Error` so a spec schema named `Error` cannot shadow it in inline mode.\n onError?: (\n error: ApiErrorLike,\n ctx: RequestContext\n ) => globalThis.Error | Promise;\n};\n\n/** Client configuration: transport, defaults, retry policy, middleware, and credentials. */\nexport type ClientConfig = {\n serverUrl?: string;\n fetch?: typeof fetch;\n headers?:\n | Record\n | (() => Record | Promise>);\n retry?: RetryConfig;\n /** Milliseconds before a request attempt aborts (covers the body read too; each retry\n * attempt gets a fresh budget). Per-call `timeout` overrides it, `0` disables it.\n * SSE streams are long-lived by design and never inherit this value. */\n timeout?: number;\n /** Send an `Idempotency-Key` header on POST/PATCH (one stable key per logical call,\n * reused across retry attempts) — which also makes those retries safe under the\n * default retry policy. `true` generates a UUID per call; a function supplies the key. */\n idempotencyKey?: boolean | (() => string);\n /** Identifies this client to the API via an `X-Redocly-Client` header (the generator\n * bakes a default). Sent only OUTSIDE browsers — a custom header would force a CORS\n * preflight. Override with your own value, or `false` to disable. */\n clientHeader?: string | false;\n middleware?: Middleware[];\n auth?: AuthCredentials;\n /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */\n errorMode?: 'throw' | 'result';\n onRequest?: Middleware['onRequest'];\n onResponse?: Middleware['onResponse'];\n onError?: Middleware['onError'];\n};\n\n/** Response readers for the per-call `parseAs` override. */\nexport type ParseAs = 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'stream';\n\n/** Per-call options: standard `RequestInit` plus a retry override, a timeout override\n * (`0` disables the config default), and a forced reader. */\nexport type RequestOptions = RequestInit & {\n retry?: RetryConfig;\n timeout?: number;\n /** Per-call idempotency key: a literal key, `true` to generate one, `false` to skip. */\n idempotencyKey?: string | boolean | (() => string);\n parseAs?: ParseAs;\n /**\n * Throw mode only: return `{ data, headers, response }` instead of the parsed body;\n * ignored in result mode. The explicit `| undefined` keeps the wrappers' emitted\n * `envelope: undefined` strip legal under `exactOptionalPropertyTypes`.\n */\n envelope?: boolean | undefined;\n};\n\n/** Throw-mode success envelope when `RequestOptions.envelope` is `true`. */\nexport type Envelope> = {\n data: TData;\n headers: THeaders;\n response: Response;\n};\n\n/** Per-call options for an SSE stream; reconnect defaults to true. */\nexport type SseOptions = RequestInit & { reconnect?: boolean; reconnectDelay?: number };\n\n/** A single decoded Server-Sent Event with its payload typed from the spec. */\nexport type ServerSentEvent = { event?: string; data: T; id?: string; retry?: number };\n\n/** Result-mode return shape: exactly one of `data`/`error` is set. */\nexport type Result =\n | { data: TData; error: undefined; response: Response }\n | { data: undefined; error: TError; response: Response };\n\n/**\n * The generated `Ops` type's shape: per-operation args/result, plus `kind: 'sse'` for\n * streams and, for paginated operations, `item` (the page's element type) and — on\n * result-mode clients only — `page` (the RAW page type `.pages()` yields, since\n * iteration unwraps the `Result` envelope the one-shot `result` carries).\n */\nexport type OpsShape = Record<\n string,\n {\n args: object;\n result: unknown;\n kind?: 'sse';\n item?: unknown;\n page?: unknown;\n /** Declared success-response headers for `{ envelope: true }` (camelCase keys). */\n headers?: object;\n /** Result-mode entries ignore the throw-only `envelope` option. */\n mode?: 'result';\n }\n>;\n\n/** The always-present client members (assigned after the operation loop — they win collisions). */\nexport type ClientCore = {\n /** Merge into the config; note `middleware` REPLACES the chain (use `use()` to compose). */\n configure(config: ClientConfig): void;\n /** Append interceptors (composes with baked/publisher middleware). */\n use(...middleware: Middleware[]): void;\n auth: {\n bearer(token: TokenProvider): void;\n basic(username: string, password: string): void;\n apiKey(scheme: string, value: TokenProvider): void;\n };\n};\n\n/**\n * The standard TypeScript optionality probe: `{}` has no required members, so\n * `{} extends A` is true exactly when every member of `A` is optional.\n */\n// oxlint-disable-next-line typescript/no-empty-object-type\ntype NoRequiredKeys = {} extends A ? true : false;\n\n/**\n * The page type `.pages()` yields: the RAW page declared by `page` (the generator\n * writes it only on result-mode paginated entries, whose `result` is the envelope),\n * or the method's own `result` (throw mode — already the raw page).\n */\ntype PageOf = Entry extends { page: unknown }\n ? Entry['page']\n : Entry['result'];\n\n/**\n * The auto-pagination members intersected onto a paginated method — present exactly when\n * the Ops entry declares `item` (the generator writes it only for paginated operations).\n * Args optionality mirrors the method's own; `unknown` otherwise (identity under `&`).\n * Iteration is error-mode-agnostic: `.pages()`/`.items()` yield raw pages/items, and a\n * failed page aborts iteration by throwing `ApiError`, even on result-mode clients; the\n * `onError` middleware hook (throw-mode-only) is not invoked.\n */\ntype Paginated = 'item' extends keyof Entry\n ? NoRequiredKeys extends true\n ? {\n pages(args?: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args?: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : {\n pages(args: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : unknown;\n\n/**\n * The stable identity every client method carries: the SPEC operationId (also set as\n * `fn.name`, but `operationId` is the explicit, minification-proof form) — a robust\n * cache key for consumer wrappers (react-query keys and the like).\n */\nexport type OperationMethodIdentity = { readonly operationId: string };\n\n/** Declared response-header bag for an Ops entry; empty object when none are declared. */\ntype HeadersOf = 'headers' extends keyof Entry\n ? NonNullable\n : Record;\n\n/**\n * Return type of a throw-mode call: the body by default, `Envelope<…>` for a literal\n * `envelope: true`, their union when the flag is statically unknowable (a widened\n * `boolean`). The `keyof` presence gate is load-bearing: without it, inits with no\n * `envelope` key (`{ headers }`, `{ signal }`) would resolve `TInit['envelope']`\n * through `TInit & RequestOptions` to `boolean | undefined` and widen to the union.\n */\nexport type EnvelopeResult<\n TData,\n THeaders,\n TInit extends RequestOptions | undefined,\n> = TInit extends undefined\n ? TData\n : 'envelope' extends keyof TInit\n ? [TInit['envelope' & keyof TInit]] extends [true]\n ? Envelope\n : [TInit['envelope' & keyof TInit]] extends [false | undefined]\n ? TData\n : TData | Envelope\n : TData;\n\n/** A one-shot method whose return shape never varies with per-call options. */\ntype BodyMethod =\n NoRequiredKeys extends true\n ? (args?: Entry['args'], init?: RequestOptions) => Promise\n : (args: Entry['args'], init?: RequestOptions) => Promise;\n\n/**\n * One-shot (non-SSE) method: default returns the body; `{ envelope: true }` returns\n * `{ data, headers, response }` with typed declared headers.\n */\ntype ThrowMethod =\n NoRequiredKeys extends true\n ? (\n args?: Entry['args'],\n init?: Init\n ) => Promise, Init>>\n : (\n args: Entry['args'],\n init?: Init\n ) => Promise, Init>>;\n\n/** The typed instance client: one bound method per operation plus the core members. */\nexport type Client = {\n [K in keyof Ops]: Ops[K] extends { kind: 'sse' }\n ? (NoRequiredKeys extends true\n ? (\n args?: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>\n : (\n args: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>) &\n OperationMethodIdentity\n : (Ops[K] extends { mode: 'result' } ? BodyMethod : ThrowMethod) &\n OperationMethodIdentity &\n Paginated;\n} & ClientCore;\n", 'errors.ts': "/** The error thrown (throw mode) for a non-2xx response, carrying the decoded error body. */\nexport class ApiError extends Error {\n public readonly url: string;\n public readonly status: number;\n public readonly statusText: string;\n public readonly body: unknown;\n constructor(url: string, status: number, statusText: string, body: unknown) {\n super(`Request failed with status ${status}`);\n this.name = 'ApiError';\n this.url = url;\n this.status = status;\n this.statusText = statusText;\n this.body = body;\n }\n}\n\n/** The error thrown when a request attempt exceeds the configured `timeout` — carries\n * the context a log line needs (which operation, what budget, which attempt). */\nexport class TimeoutError extends Error {\n public readonly operationId: string;\n public readonly timeout: number;\n public readonly attempt: number;\n constructor(operationId: string, timeout: number, attempt: number) {\n super(`Request \"${operationId}\" timed out after ${timeout} ms (attempt ${attempt})`);\n this.name = 'TimeoutError';\n this.operationId = operationId;\n this.timeout = timeout;\n this.attempt = attempt;\n }\n}\n\n/** The error to throw for an aborted request: the caller's abort reason when it is an Error. */\n// `globalThis.Error` (not bare `Error`) so a spec schema named `Error` cannot shadow it\n// when this module is embedded alongside generated types (inline mode).\nexport function abortError(signal: AbortSignal): globalThis.Error {\n const reason = (signal as { reason?: unknown }).reason;\n if (reason instanceof Error) return reason;\n return new DOMException('The operation was aborted.', 'AbortError');\n}\n", 'url.ts': @@ -21,7 +21,7 @@ export const RUNTIME_SOURCES = { 'sse.ts': "import { ApiError } from './errors.js';\nimport { readError } from './parse.js';\nimport { sleep } from './retry.js';\nimport { send, toHeaderRecord } from './send.js';\nimport type { ClientConfig, OperationContext, ServerSentEvent, SseOptions } from './types.js';\n\n/**\n * A frame delimiter: two consecutive line terminators (each CR, LF, or CRLF, per the SSE\n * spec — so mixed endings like `\\n\\r\\n` are valid boundaries, not just matching pairs).\n */\nconst FRAME_DELIMITER = /(?:\\r\\n|\\r|\\n){2}/;\n\n/**\n * A terminally malformed event stream — unparseable JSON `data` or an unbounded frame.\n * A stable bad payload, not a dropped connection, so the stream never reconnects on it.\n */\nexport class SseParseError extends Error {}\n\n/**\n * Consume a `text/event-stream` operation as typed events (capability module — wired\n * into `createClient`). Auto-reconnects on dropped connections, resuming from the last\n * seen event id via `Last-Event-ID` (backoff: the server's `retry:` value, then\n * `reconnectDelay`, then 1s — exponential with jitter, capped at 30s). A clean stream\n * end flushes a trailing frame and finishes; `break`/abort end the iterator cleanly.\n */\nexport async function* sse(\n config: ClientConfig,\n op: OperationContext,\n prepare: () => Promise<{ url: string; init: SseOptions; body?: unknown }>,\n dataKind: 'json' | 'text' = 'text'\n): AsyncGenerator> {\n let lastEventId: string | undefined;\n let serverRetry: number | undefined;\n let failures = 0;\n while (true) {\n // Re-prepare each attempt so a refresh-style TokenProvider yields a fresh credential\n // on reconnect (the auth is baked into `url` query + `init.headers`). `reconnect`,\n // `reconnectDelay`, and `signal` come from the caller's original options unchanged.\n const { url, init, body: requestBody } = await prepare();\n const { reconnect = true, reconnectDelay, ...rest } = init;\n const signal = rest.signal ?? undefined;\n if (signal?.aborted) return;\n const headers: Record = {\n Accept: 'text/event-stream',\n ...toHeaderRecord(rest.headers),\n };\n const sendHeaders =\n lastEventId === undefined ? headers : { ...headers, 'Last-Event-ID': lastEventId };\n try {\n const { response } = await send(\n config,\n op,\n url,\n // `timeout: 0` opts the stream out of a config-level timeout — an event stream\n // is long-lived by design and must not be severed after N milliseconds.\n { ...rest, method: rest.method ?? 'GET', headers: sendHeaders, timeout: 0 },\n requestBody,\n undefined,\n {}\n );\n if (!response.ok) {\n const errorBody = await readError(response);\n throw new ApiError(url, response.status, response.statusText, errorBody);\n }\n failures = 0;\n const body = response.body;\n if (!body) return;\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n try {\n while (true) {\n const { done, value } = await reader.read();\n buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });\n let match: RegExpExecArray | null;\n while ((match = FRAME_DELIMITER.exec(buffer)) !== null) {\n const raw = buffer.slice(0, match.index);\n buffer = buffer.slice(match.index + match[0].length);\n const event = parseSseFrame(raw, dataKind);\n if (event) {\n if (event.id !== undefined) lastEventId = event.id;\n if (event.retry !== undefined) serverRetry = event.retry;\n yield event as ServerSentEvent;\n }\n }\n if (done) {\n // Stream closed cleanly. Flush a final event that arrived without a trailing\n // delimiter, then finish — a clean end is not a dropped connection, so do not reconnect.\n const event = buffer.length > 0 ? parseSseFrame(buffer, dataKind) : undefined;\n if (event) {\n if (event.id !== undefined) lastEventId = event.id;\n if (event.retry !== undefined) serverRetry = event.retry;\n yield event as ServerSentEvent;\n }\n return;\n }\n // Bound memory: a server that never sends a frame delimiter would otherwise\n // grow `buffer` without limit. 1 MiB is far above any real SSE frame.\n if (buffer.length > 1048576) {\n throw new SseParseError('SSE frame exceeded 1048576 characters without a delimiter');\n }\n }\n } finally {\n await reader.cancel().catch(() => undefined);\n }\n } catch (error) {\n if (signal?.aborted) return;\n // A non-OK HTTP response (4xx/5xx) or an unparseable JSON payload is a definitive\n // error, not a transient drop — surface it instead of reconnecting in a loop (a\n // stable bad payload would otherwise reconnect forever).\n if (error instanceof ApiError || error instanceof SseParseError) throw error;\n // A transport failure (connect/DNS/reset) when opening the request, or a mid-stream\n // read error, is a dropped connection: fall through to backoff/reconnect when enabled.\n if (!reconnect) throw error;\n }\n // Only the swallowed-drop path reaches here: reconnect is on and the signal not aborted.\n failures++;\n const base = serverRetry ?? reconnectDelay ?? 1000;\n const delay = Math.min(base * Math.pow(2, failures - 1), 30_000);\n try {\n await sleep(Math.random() * delay, signal);\n } catch {\n return; // sleep rejects only on abort — end the iterator cleanly\n }\n }\n}\n\n/** Parse one raw SSE frame (its lines) into an event; returns undefined for comment-only frames. */\nexport function parseSseFrame(\n raw: string,\n dataKind: 'json' | 'text'\n): ServerSentEvent | undefined {\n let event: string | undefined;\n const dataLines: string[] = [];\n let id: string | undefined;\n let retry: number | undefined;\n let sawField = false;\n for (const line of raw.split(/\\r\\n|\\n|\\r/)) {\n if (line === '' || line.startsWith(':')) continue;\n const colon = line.indexOf(':');\n const field = colon === -1 ? line : line.slice(0, colon);\n let val = colon === -1 ? '' : line.slice(colon + 1);\n if (val.startsWith(' ')) val = val.slice(1);\n sawField = true;\n if (field === 'event') event = val;\n else if (field === 'data') dataLines.push(val);\n else if (field === 'id') id = val;\n else if (field === 'retry') {\n // ASCII digits only, per the EventSource spec — anything else is ignored\n // (`Number('')` is 0 and would zero the reconnect backoff).\n if (/^\\d+$/.test(val)) retry = Number(val);\n }\n }\n if (!sawField) return undefined;\n const dataText = dataLines.join('\\n');\n let data: unknown = dataText;\n if (dataKind === 'json' && dataText !== '') {\n try {\n data = JSON.parse(dataText);\n } catch (error) {\n throw new SseParseError(\n `Failed to parse SSE event data as JSON: ${error instanceof Error ? error.message : String(error)}`\n );\n }\n }\n return { event, data, id, retry };\n}\n", 'create-client.ts': - "import { ApiError } from './errors.js';\nimport { parse, readError } from './parse.js';\nimport { middlewareChain, send, toHeaderRecord, type SendCapabilities } from './send.js';\nimport type {\n ApiErrorLike,\n Client,\n ClientConfig,\n Middleware,\n OperationContext,\n OperationDescriptor,\n OpsShape,\n PaginationSpec,\n ParseAs,\n QueryValue,\n RequestOptions,\n SecuritySpec,\n ServerSentEvent,\n SseOptions,\n TokenProvider,\n} from './types.js';\nimport { buildUrl, substitutePath, type QueryStyle } from './url.js';\n\n/**\n * The optional behaviors `createClientCore` can dispatch to but never statically\n * imports. The package's public `createClient` wires the full set; the future\n * inline-mode assembler wires only the capabilities a spec needs.\n */\nexport type Capabilities = SendCapabilities & {\n resolveAuth?: (\n security: readonly (readonly SecuritySpec[])[],\n config: ClientConfig\n ) => Promise<{ headers: Record; query: Record }>;\n sse?: (\n config: ClientConfig,\n op: OperationContext,\n // Re-preparing per (re)connect (not a frozen url/init) lets a refresh-style\n // TokenProvider issue a fresh credential after a dropped stream reconnects.\n prepare: () => Promise<{ url: string; init: SseOptions }>,\n dataKind: 'json' | 'text'\n ) => AsyncGenerator>;\n paginate?: {\n pages: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n items: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n // The `link`-style iterators need the raw `Link` header + page URL, which the\n // parsed-page call above cannot carry (the shape mirrors paginate's `LinkPageCall`).\n pagesByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n itemsByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n };\n};\n\n/** The grouped args wire shape: path params by name plus the `params`/`body`/`headers`/`cookies` slots. */\nexport type OperationArgs = {\n params?: Record;\n body?: unknown;\n headers?: Record;\n cookies?: Record;\n} & Record;\n\n/** The response reader implied by the descriptor (before any per-call `parseAs` override). */\n/**\n * The `Accept` header matching how the response will be read — a blob/text operation\n * must not ask for `application/json` (a content-negotiating server would 406 or\n * answer with a JSON error body instead of the payload). Caller `init.headers` and\n * `config.headers` still override.\n */\nfunction acceptFor(kind: ParseAs | 'void'): string {\n if (kind === 'text') return 'text/*';\n if (kind === 'blob' || kind === 'arrayBuffer' || kind === 'stream' || kind === 'formData') {\n return '*/*';\n }\n return 'application/json'; // json | auto | void\n}\n\nfunction kindFor(op: OperationDescriptor): ParseAs | 'void' {\n if (op.responseKind === 'void' || op.responseKind === 'blob' || op.responseKind === 'text') {\n return op.responseKind;\n }\n return 'auto';\n}\n\n/** Route the grouped args by the descriptor: path values, query object, body, extra headers, cookies. */\nfunction splitArgs(op: OperationDescriptor, args: OperationArgs) {\n const path: Record = {};\n const pathNames = new Set();\n for (const param of op.params ?? []) {\n if (param.in === 'path') {\n pathNames.add(param.name);\n path[param.name] = args[param.name];\n }\n }\n // An unknown top-level key can only be a bug (usually a flat-style call shape passed\n // to a grouped client: `{ limit: 10 }` instead of `{ params: { limit: 10 } }`).\n // TypeScript catches it, but transpilers that skip type-checking would otherwise\n // ship a request that silently drops the value — fail the call loudly instead.\n for (const key of Object.keys(args)) {\n if (key === 'params' || key === 'body' || key === 'headers' || key === 'cookies') continue;\n if (pathNames.has(key)) continue;\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\". Query parameters go under params: { … } and the request body under body; valid keys are params, body, headers, cookies` +\n (pathNames.size > 0 ? `, and the path parameters (${[...pathNames].join(', ')}).` : '.')\n );\n }\n return {\n path,\n query: args.params,\n body: args.body,\n headers: args.headers,\n cookies: args.cookies,\n };\n}\n\n/**\n * The query-serialization hints for the descriptor's query params. A spec is built only\n * when the param deviates from the OpenAPI defaults (`form` + `explode: true`, encoded),\n * and always fully resolved — so `explode: false` or `allowReserved` alone (no `style`)\n * are honored, and an omitted `explode` keeps the exploded default.\n */\nfunction queryStyles(op: OperationDescriptor): Record | undefined {\n let styles: Record | undefined;\n for (const param of op.params ?? []) {\n if (param.in !== 'query') continue;\n const deviates =\n (param.style !== undefined && param.style !== 'form') ||\n param.explode === false ||\n param.allowReserved === true;\n if (!deviates) continue;\n styles ??= {};\n styles[param.name] = {\n style: param.style ?? 'form',\n explode: param.explode ?? true,\n allowReserved: param.allowReserved,\n };\n }\n return styles;\n}\n\n/** Stringify caller-supplied extra headers, skipping empty entries. */\nfunction stringHeaders(headers: Record | undefined): Record {\n const out: Record = {};\n for (const [key, value] of Object.entries(headers ?? {})) {\n if (value !== undefined && value !== null) out[key] = String(value);\n }\n return out;\n}\n\n/** Build the request pieces an attempt needs: the final URL and the merged per-call init. */\nasync function prepareRequest(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions | SseOptions,\n caps: Capabilities\n): Promise<{ url: string; init: RequestOptions; body: unknown }> {\n const { path, query, body, headers, cookies } = splitArgs(op, args);\n const authed: { headers: Record; query: Record } =\n op.security?.length && caps.resolveAuth\n ? await caps.resolveAuth(op.security, config)\n : { headers: {}, query: {} };\n // Cookie params join the auth-injected cookies in one `Cookie` header (values\n // percent-encoded, like auth cookies). Server-side only — browsers own the header.\n const cookiePairs = Object.entries(cookies ?? {})\n .filter(([, value]) => value !== undefined && value !== null)\n .map(([cookieName, value]) => `${cookieName}=${encodeURIComponent(String(value))}`);\n if (cookiePairs.length > 0) {\n authed.headers.Cookie = [authed.headers.Cookie, ...cookiePairs].filter(Boolean).join('; ');\n }\n const fullQuery: Record = { ...query, ...authed.query };\n const url = buildUrl(\n config.serverUrl ?? '',\n substitutePath(op.path, path),\n Object.keys(fullQuery).length > 0 ? fullQuery : undefined,\n queryStyles(op)\n );\n const mergedInit: RequestOptions = {\n ...init,\n method: op.method.toUpperCase(),\n // Precedence, lowest → highest (later spreads win): injected auth → explicit\n // header params → caller `init.headers` — the caller always overrides both.\n headers: {\n ...authed.headers,\n ...stringHeaders(headers),\n ...toHeaderRecord(init.headers),\n },\n };\n return { url, init: mergedInit, body };\n}\n\n/** One non-SSE call: send, then branch on the configured error mode. */\nasync function execute(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions,\n caps: Capabilities\n): Promise {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { parseAs, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const { response, context } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (config.errorMode === 'result') {\n if (!response.ok) {\n return { data: undefined, error: await readError(response), response };\n }\n return { data: await parse(response, readKind), error: undefined, response };\n }\n if (!response.ok) {\n let error: globalThis.Error = new ApiError(\n context.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n // Thread the error through each middleware's onError in turn (each may replace it).\n for (const mw of middlewareChain(config)) {\n if (mw.onError) error = await mw.onError(error as ApiErrorLike, context);\n }\n throw error;\n }\n return parse(response, readKind);\n}\n\n/** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */\nfunction paginateCapability(caps: Capabilities, op: OperationDescriptor) {\n if (!caps.paginate) {\n throw new Error(`Pagination capability not wired: cannot iterate operation \"${op.id}\"`);\n }\n return caps.paginate;\n}\n\n/**\n * The per-page call the iterators drive: the method itself in throw mode; in result\n * mode a wrapper that unwraps the `{ data, error, response }` envelope — the page\n * pointers are data-rooted — rethrowing a failed page as `ApiError` (iteration is\n * error-mode-agnostic; the throw-mode-only `onError` middleware hook is not invoked).\n */\nfunction pageCall(\n method: (args?: OperationArgs, init?: RequestOptions) => Promise,\n config: ClientConfig\n) {\n if (config.errorMode !== 'result') return method;\n return async (args?: OperationArgs, init?: RequestOptions) => {\n const envelope = (await method(args, init)) as {\n data: unknown;\n error: unknown;\n response: Response;\n };\n // Failure is `!response.ok` — NOT `data === undefined`: a successful bodyless page\n // (204/void) also parses to undefined data, and a failed page's `error` can be\n // undefined too (unreadable body). The pointers then miss on the undefined data\n // and iteration stops cleanly, which is the correct semantics for an empty page.\n if (!envelope.response.ok) {\n const { response } = envelope;\n throw new ApiError(response.url, response.status, response.statusText, envelope.error);\n }\n return envelope.data;\n };\n}\n\n/**\n * The per-page call the `link`-style iterators drive: like `execute`, but returning the\n * parsed page together with the raw `Link` header and the page's own URL (for resolving\n * a relative `rel=\"next\"` target). Error-mode-agnostic like all iteration: a failed\n * page throws `ApiError` even on result-mode clients.\n */\nfunction linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) {\n return async (args: OperationArgs = {}, init: RequestOptions = {}) => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const { parseAs, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { response } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (!response.ok) {\n throw new ApiError(\n prepared.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n }\n return {\n page: await parse(response, readKind),\n linkHeader: response.headers.get('link'),\n // Some `Response` implementations leave `url` empty (mocks, constructed responses).\n url: response.url === '' ? prepared.url : response.url,\n };\n };\n}\n\n/**\n * Build a typed instance client over operation descriptors: one real bound method per\n * operation (attached by a construction-time loop — no Proxy), plus the core members\n * (`configure`/`use`/`auth`), which are assigned AFTER the loop so they win any name\n * collision with an operation. All behavior dispatches through the capability seam.\n */\nexport function createClientCore<\n Ops extends OpsShape,\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n>(\n operations: Record,\n initial: ClientConfig> = {},\n caps: Capabilities = {}\n): Client> {\n // The literal-union narrowing is a compile-time DX contract only; internally the\n // runtime works with the base (string-typed) context. One cast at this boundary —\n // `ClientConfig` is not assignable to `ClientConfig` (middleware ctx\n // params are contravariant).\n const given = initial as ClientConfig;\n // Private mutable config; the middleware array is copied so `use()` never mutates the caller's.\n const config: ClientConfig = { ...given, middleware: [...(given.middleware ?? [])] };\n const client = {} as Record;\n\n for (const [name, op] of Object.entries(operations)) {\n if (op.responseKind === 'sse') {\n const method = (args: OperationArgs = {}, init: SseOptions = {}) => {\n if (!caps.sse) {\n throw new Error(`SSE capability not wired: cannot stream operation \"${op.id}\"`);\n }\n const stream = caps.sse;\n return (async function* () {\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // A thunk the stream re-runs on every (re)connect, so auth (which `prepareRequest`\n // resolves) is refreshed per attempt rather than frozen at the first connect.\n const prepare = async () => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n return { url: prepared.url, init: prepared.init as SseOptions, body: prepared.body };\n };\n yield* stream(config, opCtx, prepare, op.sseDataKind ?? 'text');\n })();\n };\n // Consumers key off the function reference (cache keys, `OPERATIONS[fn.name]`), so\n // each closure carries its operationId instead of an inferred binding name.\n // `operationId` is the explicit, minification-proof form of the same identity\n // (the SPEC operationId — `name` is the emitted key, which a collision may rename).\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n client[name] = method;\n } else {\n const method = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n execute(config, op, args, init, caps);\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n const spec = op.pagination;\n // Paginated ops keep their one-shot call and gain `.pages`/`.items`, dispatching\n // through the capability seam (like SSE: absent capability throws descriptively).\n // Iteration is error-mode-agnostic: the iterators' pointers are data-rooted, so on\n // a result-mode client (`errorMode` is fixed at construction — `configure()`\n // ignores it) each page's envelope is unwrapped before it reaches the capability.\n // A failed page aborts iteration by throwing ApiError, even on result-mode\n // clients; the `onError` middleware hook (throw-mode-only) is not invoked.\n client[name] =\n spec === undefined\n ? method\n : spec.style === 'link'\n ? Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pagesByLink(\n linkPageCall(config, op, caps),\n args,\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).itemsByLink(\n linkPageCall(config, op, caps),\n spec,\n args,\n init\n ),\n })\n : Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pages(pageCall(method, config), spec, args, init),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).items(pageCall(method, config), spec, args, init),\n });\n }\n }\n\n // Core members are assigned AFTER the operation loop — they win over colliding op names.\n client.configure = (next: ClientConfig): void => {\n // `errorMode` is fixed at generate time (it shapes the static types); flipping it at\n // runtime would silently desync return shapes from `Client`, so it is ignored.\n const { errorMode: _fixed, auth, ...rest } = next;\n Object.assign(config, rest);\n // `auth` merges into existing credentials (like the `auth.*` setters) rather than\n // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set\n // basic/apiKey. `apiKey` merges per scheme.\n if (auth) {\n config.auth = {\n ...config.auth,\n ...auth,\n ...(auth.apiKey ? { apiKey: { ...config.auth?.apiKey, ...auth.apiKey } } : {}),\n };\n }\n };\n client.use = (...middleware: Middleware[]): void => {\n // Reassign (don't push) so a caller-provided `middleware` array isn't mutated.\n config.middleware = [...(config.middleware ?? []), ...middleware];\n };\n client.auth = {\n bearer(token: TokenProvider): void {\n config.auth = { ...config.auth, bearer: token };\n },\n basic(username: string, password: string): void {\n config.auth = { ...config.auth, basic: { username, password } };\n },\n apiKey(scheme: string, value: TokenProvider): void {\n config.auth = { ...config.auth, apiKey: { ...config.auth?.apiKey, [scheme]: value } };\n },\n };\n\n return client as Client>;\n}\n", + "import { ApiError } from './errors.js';\nimport { parse, readError } from './parse.js';\nimport { middlewareChain, send, toHeaderRecord, type SendCapabilities } from './send.js';\nimport type {\n ApiErrorLike,\n Client,\n ClientConfig,\n Middleware,\n OperationContext,\n OperationDescriptor,\n OpsShape,\n PaginationSpec,\n ParseAs,\n QueryValue,\n RequestOptions,\n ResponseHeaderSpec,\n SecuritySpec,\n ServerSentEvent,\n SseOptions,\n TokenProvider,\n} from './types.js';\nimport { buildUrl, substitutePath, type QueryStyle } from './url.js';\n\n/**\n * The optional behaviors `createClientCore` can dispatch to but never statically\n * imports. The package's public `createClient` wires the full set; the future\n * inline-mode assembler wires only the capabilities a spec needs.\n */\nexport type Capabilities = SendCapabilities & {\n resolveAuth?: (\n security: readonly (readonly SecuritySpec[])[],\n config: ClientConfig\n ) => Promise<{ headers: Record; query: Record }>;\n sse?: (\n config: ClientConfig,\n op: OperationContext,\n // Re-preparing per (re)connect (not a frozen url/init) lets a refresh-style\n // TokenProvider issue a fresh credential after a dropped stream reconnects.\n prepare: () => Promise<{ url: string; init: SseOptions }>,\n dataKind: 'json' | 'text'\n ) => AsyncGenerator>;\n paginate?: {\n pages: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n items: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n // The `link`-style iterators need the raw `Link` header + page URL, which the\n // parsed-page call above cannot carry (the shape mirrors paginate's `LinkPageCall`).\n pagesByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n itemsByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n };\n};\n\n/** The grouped args wire shape: path params by name plus the `params`/`body`/`headers`/`cookies` slots. */\nexport type OperationArgs = {\n params?: Record;\n body?: unknown;\n headers?: Record;\n cookies?: Record;\n} & Record;\n\n/** The response reader implied by the descriptor (before any per-call `parseAs` override). */\n/**\n * The `Accept` header matching how the response will be read — a blob/text operation\n * must not ask for `application/json` (a content-negotiating server would 406 or\n * answer with a JSON error body instead of the payload). Caller `init.headers` and\n * `config.headers` still override.\n */\nfunction acceptFor(kind: ParseAs | 'void'): string {\n if (kind === 'text') return 'text/*';\n if (kind === 'blob' || kind === 'arrayBuffer' || kind === 'stream' || kind === 'formData') {\n return '*/*';\n }\n return 'application/json'; // json | auto | void\n}\n\nfunction kindFor(op: OperationDescriptor): ParseAs | 'void' {\n if (op.responseKind === 'void' || op.responseKind === 'blob' || op.responseKind === 'text') {\n return op.responseKind;\n }\n return 'auto';\n}\n\n/** Route the grouped args by the descriptor: path values, query object, body, extra headers, cookies. */\nfunction splitArgs(op: OperationDescriptor, args: OperationArgs) {\n const path: Record = {};\n const pathNames = new Set();\n for (const param of op.params ?? []) {\n if (param.in === 'path') {\n pathNames.add(param.name);\n path[param.name] = args[param.name];\n }\n }\n // An unknown top-level key can only be a bug (usually a flat-style call shape passed\n // to a grouped client: `{ limit: 10 }` instead of `{ params: { limit: 10 } }`).\n // TypeScript catches it, but transpilers that skip type-checking would otherwise\n // ship a request that silently drops the value — fail the call loudly instead.\n for (const key of Object.keys(args)) {\n if (key === 'params' || key === 'body' || key === 'headers' || key === 'cookies') continue;\n if (pathNames.has(key)) continue;\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\". Query parameters go under params: { … } and the request body under body; valid keys are params, body, headers, cookies` +\n (pathNames.size > 0 ? `, and the path parameters (${[...pathNames].join(', ')}).` : '.')\n );\n }\n return {\n path,\n query: args.params,\n body: args.body,\n headers: args.headers,\n cookies: args.cookies,\n };\n}\n\n/**\n * The query-serialization hints for the descriptor's query params. A spec is built only\n * when the param deviates from the OpenAPI defaults (`form` + `explode: true`, encoded),\n * and always fully resolved — so `explode: false` or `allowReserved` alone (no `style`)\n * are honored, and an omitted `explode` keeps the exploded default.\n */\nfunction queryStyles(op: OperationDescriptor): Record | undefined {\n let styles: Record | undefined;\n for (const param of op.params ?? []) {\n if (param.in !== 'query') continue;\n const deviates =\n (param.style !== undefined && param.style !== 'form') ||\n param.explode === false ||\n param.allowReserved === true;\n if (!deviates) continue;\n styles ??= {};\n styles[param.name] = {\n style: param.style ?? 'form',\n explode: param.explode ?? true,\n allowReserved: param.allowReserved,\n };\n }\n return styles;\n}\n\n/** Stringify caller-supplied extra headers, skipping empty entries. */\nfunction stringHeaders(headers: Record | undefined): Record {\n const out: Record = {};\n for (const [key, value] of Object.entries(headers ?? {})) {\n if (value !== undefined && value !== null) out[key] = String(value);\n }\n return out;\n}\n\n/** Build the request pieces an attempt needs: the final URL and the merged per-call init. */\nasync function prepareRequest(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions | SseOptions,\n caps: Capabilities\n): Promise<{ url: string; init: RequestOptions; body: unknown }> {\n const { path, query, body, headers, cookies } = splitArgs(op, args);\n const authed: { headers: Record; query: Record } =\n op.security?.length && caps.resolveAuth\n ? await caps.resolveAuth(op.security, config)\n : { headers: {}, query: {} };\n // Cookie params join the auth-injected cookies in one `Cookie` header (values\n // percent-encoded, like auth cookies). Server-side only — browsers own the header.\n const cookiePairs = Object.entries(cookies ?? {})\n .filter(([, value]) => value !== undefined && value !== null)\n .map(([cookieName, value]) => `${cookieName}=${encodeURIComponent(String(value))}`);\n if (cookiePairs.length > 0) {\n authed.headers.Cookie = [authed.headers.Cookie, ...cookiePairs].filter(Boolean).join('; ');\n }\n const fullQuery: Record = { ...query, ...authed.query };\n const url = buildUrl(\n config.serverUrl ?? '',\n substitutePath(op.path, path),\n Object.keys(fullQuery).length > 0 ? fullQuery : undefined,\n queryStyles(op)\n );\n const mergedInit: RequestOptions = {\n ...init,\n method: op.method.toUpperCase(),\n // Precedence, lowest → highest (later spreads win): injected auth → explicit\n // header params → caller `init.headers` — the caller always overrides both.\n headers: {\n ...authed.headers,\n ...stringHeaders(headers),\n ...toHeaderRecord(init.headers),\n },\n };\n return { url, init: mergedInit, body };\n}\n\n/** Coerce a single declared response header value; omit when absent or unparsable. */\nfunction coerceResponseHeader(\n raw: string | null,\n type: ResponseHeaderSpec['type']\n): string | number | boolean | undefined {\n if (raw === null) return undefined;\n if (type === 'number') {\n if (raw.trim() === '') return undefined;\n const value = Number(raw);\n return Number.isFinite(value) ? value : undefined;\n }\n if (type === 'boolean') {\n if (raw === 'true') return true;\n if (raw === 'false') return false;\n return undefined;\n }\n return raw;\n}\n\n/** Build the camelCase declared-header bag for a throw-mode envelope. */\nfunction readEnvelopeHeaders(\n response: Response,\n specs: readonly ResponseHeaderSpec[] | undefined\n): Record {\n const headers: Record = {};\n for (const spec of specs ?? []) {\n const value = coerceResponseHeader(response.headers.get(spec.name), spec.type);\n if (value !== undefined) headers[spec.key] = value;\n }\n return headers;\n}\n\n/** One non-SSE call: send, then branch on the configured error mode. */\nasync function execute(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions,\n caps: Capabilities\n): Promise {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // `parseAs` / `envelope` are client options, not fetch RequestInit fields.\n const { parseAs, envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const { response, context } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (config.errorMode === 'result') {\n if (!response.ok) {\n return { data: undefined, error: await readError(response), response };\n }\n return { data: await parse(response, readKind), error: undefined, response };\n }\n if (!response.ok) {\n let error: globalThis.Error = new ApiError(\n context.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n // Thread the error through each middleware's onError in turn (each may replace it).\n for (const mw of middlewareChain(config)) {\n if (mw.onError) error = await mw.onError(error as ApiErrorLike, context);\n }\n throw error;\n }\n const data = await parse(response, readKind);\n if (envelope === true) {\n return {\n data,\n headers: readEnvelopeHeaders(response, op.responseHeaders),\n response,\n };\n }\n return data;\n}\n\n/** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */\nfunction paginateCapability(caps: Capabilities, op: OperationDescriptor) {\n if (!caps.paginate) {\n throw new Error(`Pagination capability not wired: cannot iterate operation \"${op.id}\"`);\n }\n return caps.paginate;\n}\n\n/**\n * The per-page call the iterators drive: the method itself in throw mode; in result\n * mode a wrapper that unwraps the `{ data, error, response }` envelope — the page\n * pointers are data-rooted — rethrowing a failed page as `ApiError` (iteration is\n * error-mode-agnostic; the throw-mode-only `onError` middleware hook is not invoked).\n */\nfunction pageCall(\n method: (args?: OperationArgs, init?: RequestOptions) => Promise,\n config: ClientConfig\n) {\n const callWithoutEnvelope = (args?: OperationArgs, init?: RequestOptions) => {\n if (!init || init.envelope === undefined) return method(args, init);\n const { envelope: _envelope, ...pageInit } = init;\n return method(args, pageInit);\n };\n if (config.errorMode !== 'result') return callWithoutEnvelope;\n return async (args?: OperationArgs, init?: RequestOptions) => {\n const envelope = (await callWithoutEnvelope(args, init)) as {\n data: unknown;\n error: unknown;\n response: Response;\n };\n // Failure is `!response.ok` — NOT `data === undefined`: a successful bodyless page\n // (204/void) also parses to undefined data, and a failed page's `error` can be\n // undefined too (unreadable body). The pointers then miss on the undefined data\n // and iteration stops cleanly, which is the correct semantics for an empty page.\n if (!envelope.response.ok) {\n const { response } = envelope;\n throw new ApiError(response.url, response.status, response.statusText, envelope.error);\n }\n return envelope.data;\n };\n}\n\n/**\n * The per-page call the `link`-style iterators drive: like `execute`, but returning the\n * parsed page together with the raw `Link` header and the page's own URL (for resolving\n * a relative `rel=\"next\"` target). Error-mode-agnostic like all iteration: a failed\n * page throws `ApiError` even on result-mode clients.\n */\nfunction linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) {\n return async (args: OperationArgs = {}, init: RequestOptions = {}) => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const { parseAs, envelope: _envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { response } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (!response.ok) {\n throw new ApiError(\n prepared.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n }\n return {\n page: await parse(response, readKind),\n linkHeader: response.headers.get('link'),\n // Some `Response` implementations leave `url` empty (mocks, constructed responses).\n url: response.url === '' ? prepared.url : response.url,\n };\n };\n}\n\n/**\n * Build a typed instance client over operation descriptors: one real bound method per\n * operation (attached by a construction-time loop — no Proxy), plus the core members\n * (`configure`/`use`/`auth`), which are assigned AFTER the loop so they win any name\n * collision with an operation. All behavior dispatches through the capability seam.\n */\nexport function createClientCore<\n Ops extends OpsShape,\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n>(\n operations: Record,\n initial: ClientConfig> = {},\n caps: Capabilities = {}\n): Client> {\n // The literal-union narrowing is a compile-time DX contract only; internally the\n // runtime works with the base (string-typed) context. One cast at this boundary —\n // `ClientConfig` is not assignable to `ClientConfig` (middleware ctx\n // params are contravariant).\n const given = initial as ClientConfig;\n // Private mutable config; the middleware array is copied so `use()` never mutates the caller's.\n const config: ClientConfig = { ...given, middleware: [...(given.middleware ?? [])] };\n const client = {} as Record;\n\n for (const [name, op] of Object.entries(operations)) {\n if (op.responseKind === 'sse') {\n const method = (args: OperationArgs = {}, init: SseOptions = {}) => {\n if (!caps.sse) {\n throw new Error(`SSE capability not wired: cannot stream operation \"${op.id}\"`);\n }\n const stream = caps.sse;\n return (async function* () {\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // A thunk the stream re-runs on every (re)connect, so auth (which `prepareRequest`\n // resolves) is refreshed per attempt rather than frozen at the first connect.\n const prepare = async () => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n return { url: prepared.url, init: prepared.init as SseOptions, body: prepared.body };\n };\n yield* stream(config, opCtx, prepare, op.sseDataKind ?? 'text');\n })();\n };\n // Consumers key off the function reference (cache keys, `OPERATIONS[fn.name]`), so\n // each closure carries its operationId instead of an inferred binding name.\n // `operationId` is the explicit, minification-proof form of the same identity\n // (the SPEC operationId — `name` is the emitted key, which a collision may rename).\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n client[name] = method;\n } else {\n const method = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n execute(config, op, args, init, caps);\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n const spec = op.pagination;\n // Paginated ops keep their one-shot call and gain `.pages`/`.items`, dispatching\n // through the capability seam (like SSE: absent capability throws descriptively).\n // Iteration is error-mode-agnostic: the iterators' pointers are data-rooted, so on\n // a result-mode client (`errorMode` is fixed at construction — `configure()`\n // ignores it) each page's envelope is unwrapped before it reaches the capability.\n // A failed page aborts iteration by throwing ApiError, even on result-mode\n // clients; the `onError` middleware hook (throw-mode-only) is not invoked.\n client[name] =\n spec === undefined\n ? method\n : spec.style === 'link'\n ? Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pagesByLink(\n linkPageCall(config, op, caps),\n args,\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).itemsByLink(\n linkPageCall(config, op, caps),\n spec,\n args,\n init\n ),\n })\n : Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pages(pageCall(method, config), spec, args, init),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).items(pageCall(method, config), spec, args, init),\n });\n }\n }\n\n // Core members are assigned AFTER the operation loop — they win over colliding op names.\n client.configure = (next: ClientConfig): void => {\n // `errorMode` is fixed at generate time (it shapes the static types); flipping it at\n // runtime would silently desync return shapes from `Client`, so it is ignored.\n const { errorMode: _fixed, auth, ...rest } = next;\n Object.assign(config, rest);\n // `auth` merges into existing credentials (like the `auth.*` setters) rather than\n // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set\n // basic/apiKey. `apiKey` merges per scheme.\n if (auth) {\n config.auth = {\n ...config.auth,\n ...auth,\n ...(auth.apiKey ? { apiKey: { ...config.auth?.apiKey, ...auth.apiKey } } : {}),\n };\n }\n };\n client.use = (...middleware: Middleware[]): void => {\n // Reassign (don't push) so a caller-provided `middleware` array isn't mutated.\n config.middleware = [...(config.middleware ?? []), ...middleware];\n };\n client.auth = {\n bearer(token: TokenProvider): void {\n config.auth = { ...config.auth, bearer: token };\n },\n basic(username: string, password: string): void {\n config.auth = { ...config.auth, basic: { username, password } };\n },\n apiKey(scheme: string, value: TokenProvider): void {\n config.auth = { ...config.auth, apiKey: { ...config.auth?.apiKey, [scheme]: value } };\n },\n };\n\n return client as Client>;\n}\n", 'paginate.ts': "import type { OperationArgs } from './create-client.js';\nimport type { PaginationSpec, QueryValue, RequestOptions } from './types.js';\n\n/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nexport function resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nexport async function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nexport async function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\nexport type LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nexport function linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nexport async function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nexport async function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n", } as const; diff --git a/packages/client-generator/src/emitters/support.ts b/packages/client-generator/src/emitters/support.ts index 392ec3cadb..8844646229 100644 --- a/packages/client-generator/src/emitters/support.ts +++ b/packages/client-generator/src/emitters/support.ts @@ -1,5 +1,7 @@ // Low-level text helpers shared across the emitters. Private to `emitters/`. +import { sanitizeIdentifier } from './identifier.js'; + /** * Upper-case the first character of an operation name. We don't normalize the * rest because almost every spec uses camelCase or PascalCase, and names that @@ -14,6 +16,22 @@ export function pascalCase(name: string): string { return name[0].toUpperCase() + name.slice(1); } +/** + * CamelCase property key for a response-header wire name (`Pagination-Total` → + * `paginationTotal`). + */ +export function headerPropertyKey(wireName: string): string { + const camelCase = wireName + .split(/[-_]/) + .filter((part) => part.length > 0) + .map((part, index) => { + const lower = part.toLowerCase(); + return index === 0 ? lower : lower.charAt(0).toUpperCase() + lower.slice(1); + }) + .join(''); + return sanitizeIdentifier(camelCase); +} + export function splitLines(text: string): string[] { return text .replace(/\r\n/g, '\n') diff --git a/packages/client-generator/src/emitters/tanstack-query.ts b/packages/client-generator/src/emitters/tanstack-query.ts index 8c0ead7d6c..d285c0bde4 100644 --- a/packages/client-generator/src/emitters/tanstack-query.ts +++ b/packages/client-generator/src/emitters/tanstack-query.ts @@ -143,18 +143,18 @@ function optionsMember(op: OperationModel): string { return ( ` ${op.name}Options: (${params}) => queryOptions({\n` + ` queryKey: ${op.name}QueryKey(${keyArg}),\n` + - ` queryFn: ({ signal }) => instance.${op.name}(${callArgs}, { ...init, signal }),\n` + + ` queryFn: ({ signal }) => instance.${op.name}(${callArgs}, { ...init, signal, envelope: undefined }),\n` + ` })` ); } -/** `Mutation(init?)` — per-call `RequestOptions` (headers, a retry override) reach the mutation. */ +/** `Mutation(init?)` — per-call options (headers, a retry override) reach the mutation. */ function mutationMember(op: OperationModel, prefix: string | undefined): string { const mutationFn = hasInputs(op) - ? `(vars: ${variablesName(op)}) => instance.${op.name}(vars, init)` - : `() => instance.${op.name}({}, init)`; + ? `(vars: ${variablesName(op)}) => instance.${op.name}(vars, { ...init, envelope: undefined })` + : `() => instance.${op.name}({}, { ...init, envelope: undefined })`; return ( - ` ${op.name}Mutation: (init?: RequestOptions) => ({\n` + + ` ${op.name}Mutation: (${INIT_PARAM}) => ({\n` + ` mutationKey: [${keyElements(op, prefix)}] as const,\n` + ` mutationFn: ${mutationFn},\n` + ` })` @@ -178,7 +178,7 @@ function infiniteMember( return ( ` ${op.name}InfiniteOptions: (${params}) => infiniteQueryOptions({\n` + ` queryKey: [...${op.name}QueryKey(${keyArg}), "infinite"] as const,\n` + - ` queryFn: ({ pageParam, signal }) => instance.${op.name}(${override}, { ...init, signal }),\n` + + ` queryFn: ({ pageParam, signal }) => instance.${op.name}(${override}, { ...init, signal, envelope: undefined }),\n` + nextPageSource(model, op, spec) + ` })` ); @@ -255,13 +255,20 @@ function cursorStopChecks(model: ApiModel, op: OperationModel, pointer: string): ]; } +/** + * The `init` parameter every factory takes. The throw-only `envelope` option is + * excluded from the type and stripped in the forwarding calls — cached query data + * must stay the plain body. + */ +const INIT_PARAM = 'init?: Omit'; + /** The shared `(vars, init?)` parameter list plus how `vars` reaches the key and the call. */ function varsPieces(op: OperationModel): { params: string; keyArg: string; callArgs: string } { if (!hasInputs(op)) { - return { params: 'init?: RequestOptions', keyArg: '', callArgs: '{}' }; + return { params: INIT_PARAM, keyArg: '', callArgs: '{}' }; } return { - params: `vars: ${variablesName(op)}, init?: RequestOptions`, + params: `vars: ${variablesName(op)}, ${INIT_PARAM}`, keyArg: 'vars', callArgs: 'vars', }; diff --git a/packages/client-generator/src/emitters/ts.ts b/packages/client-generator/src/emitters/ts.ts index dbd7154ed6..ce627e4811 100644 --- a/packages/client-generator/src/emitters/ts.ts +++ b/packages/client-generator/src/emitters/ts.ts @@ -127,6 +127,23 @@ export function arrow(params: ts.ParameterDeclaration[], body: ts.ConciseBody): ); } +/** An arrow with type parameters, an explicit return type, and a body. */ +export function typedArrow( + typeParameters: ts.TypeParameterDeclaration[], + params: ts.ParameterDeclaration[], + returnType: ts.TypeNode, + body: ts.ConciseBody +): ts.ArrowFunction { + return factory.createArrowFunction( + undefined, + typeParameters, + params, + returnType, + factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + body + ); +} + /** `[] as const`. */ export function constArray(elements: ts.Expression[]): ts.Expression { return factory.createAsExpression( diff --git a/packages/client-generator/src/emitters/wrapper-support.ts b/packages/client-generator/src/emitters/wrapper-support.ts index f5ba14046b..07971d8dbc 100644 --- a/packages/client-generator/src/emitters/wrapper-support.ts +++ b/packages/client-generator/src/emitters/wrapper-support.ts @@ -79,24 +79,31 @@ export function varsParam(op: OperationModel): ts.ParameterDeclaration { ); } -/** An `init?: RequestOptions` parameter. */ +/** + * An `init?: Omit` parameter. The wrappers cache the + * fetched body, so the throw-only `envelope` option is excluded from the type and + * stripped at runtime by `sdkCall`. + */ export function initParam(): ts.ParameterDeclaration { return factory.createParameterDeclaration( undefined, undefined, 'init', factory.createToken(ts.SyntaxKind.QuestionToken), - factory.createTypeReferenceNode('RequestOptions') + factory.createTypeReferenceNode('Omit', [ + factory.createTypeReferenceNode('RequestOptions'), + factory.createLiteralTypeNode(factory.createStringLiteral('envelope')), + ]) ); } /** - * The forwarding call to the sdk operation function. Argument order comes from the - * shared `operationSignature`, so it lines up with the sdk's parameter list by - * construction. `grouped` passes the source object (when inputs); `flat` spreads - * `.` (URL-template order), then `.params` / `.body` / - * `.headers` for the slots the op has. `init` is appended last when `withInit` - * (the sdk function's trailing `RequestOptions`). + * The forwarding call to the sdk operation function; argument order comes from the + * shared `operationSignature`. `grouped` passes the source object — `{}` for a + * no-input op with an init, which must not land in the `(args?, init?)` args slot; + * `flat` spreads `.`, then `.params` / `.body` / `.headers`. + * `withInit` appends `{ ...init, envelope: undefined }` — a runtime strip, since + * `initParam`'s `Omit` is type-only. */ export function sdkCall( op: OperationModel, @@ -110,6 +117,7 @@ export function sdkCall( if (argsStyle === 'grouped') { if (sig.hasInputs) args.push(sourceIdent); + else if (withInit) args.push(factory.createObjectLiteralExpression([])); } else { for (const { ident } of sig.pathParams) { args.push(factory.createPropertyAccessExpression(sourceIdent, ident)); @@ -119,7 +127,14 @@ export function sdkCall( if (sig.hasHeaders) args.push(factory.createPropertyAccessExpression(sourceIdent, 'headers')); if (sig.hasCookies) args.push(factory.createPropertyAccessExpression(sourceIdent, 'cookies')); } - if (withInit) args.push(factory.createIdentifier('init')); + if (withInit) { + args.push( + factory.createObjectLiteralExpression([ + factory.createSpreadAssignment(factory.createIdentifier('init')), + factory.createPropertyAssignment('envelope', factory.createIdentifier('undefined')), + ]) + ); + } return factory.createCallExpression(factory.createIdentifier(op.name), undefined, args); } diff --git a/packages/client-generator/src/index.ts b/packages/client-generator/src/index.ts index 91e03d6fe9..848a0e4d55 100644 --- a/packages/client-generator/src/index.ts +++ b/packages/client-generator/src/index.ts @@ -32,6 +32,8 @@ export type { Client, ClientConfig, ClientCore, + Envelope, + EnvelopeResult, OperationDescriptor, OperationMethodIdentity, OpsShape, diff --git a/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts b/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts index 133bab3d90..ff84090055 100644 --- a/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts +++ b/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts @@ -669,23 +669,81 @@ describe('buildSuccessResponses', () => { expect(op.successResponses[0].status).toBe(201); }); - it('collects declared response header names, lowercased (the link-pagination fit signal)', () => { + it('collects declared response headers with schemas (lowercased names for link-pagination fit)', () => { const op = buildOpOnly( opWithResponses({ '200': { - headers: { Link: { schema: { type: 'string' } }, 'X-Total': {} }, + headers: { + Link: { schema: { type: 'string' } }, + 'X-Total': {}, + 'Pagination-Total': { schema: { type: 'integer' }, required: true }, + }, content: { 'application/json': { schema: { type: 'string' } } }, }, }) ); - expect(op.successResponses[0].headers).toEqual(['link', 'x-total']); + expect(op.successResponseHeaders).toEqual([ + { name: 'link', schema: { kind: 'scalar', scalar: 'string' } }, + { name: 'x-total', schema: { kind: 'unknown' } }, + { + name: 'pagination-total', + schema: { kind: 'scalar', scalar: 'integer' }, + required: true, + }, + ]); // No declared headers → the field stays absent, not an empty array. const bare = buildOpOnly( opWithResponses({ '200': { content: { 'application/json': { schema: { type: 'string' } } } }, }) ); - expect(bare.successResponses[0].headers).toBeUndefined(); + expect(bare.successResponseHeaders).toBeUndefined(); + }); + + it('collects declared headers from a bodyless success response', () => { + const op = buildOpOnly( + opWithResponses({ + '201': { + headers: { + Location: { schema: { type: 'string' }, required: true }, + }, + description: 'created', + }, + }) + ); + + expect(op.successResponses).toEqual([]); + expect(op.successResponseHeaders).toEqual([ + { + name: 'location', + schema: { kind: 'scalar', scalar: 'string' }, + required: true, + }, + ]); + }); + + it('resolves a referenced scalar response-header schema', () => { + const op = buildOpOnly({ + ...opWithResponses({ + '200': { + headers: { + 'Pagination-Total': { schema: { $ref: '#/components/schemas/Count' } }, + }, + description: 'ok', + content: { 'application/json': { schema: { type: 'array', items: {} } } }, + }, + }), + components: { + schemas: { + Count: { type: 'integer' }, + }, + }, + }); + + expect(op.successResponseHeaders?.[0].schema).toEqual({ + kind: 'scalar', + scalar: 'integer', + }); }); it('accepts the 2XX range wildcard, with an explicit code taking precedence', () => { diff --git a/packages/client-generator/src/intermediate-representation/build.ts b/packages/client-generator/src/intermediate-representation/build.ts index 7019df4b8b..a08a9ee84c 100644 --- a/packages/client-generator/src/intermediate-representation/build.ts +++ b/packages/client-generator/src/intermediate-representation/build.ts @@ -28,6 +28,7 @@ import type { PropertyModel, RequestBodyModel, ResponseBodyModel, + ResponseHeaderModel, ScalarKind, SchemaMetadata, SchemaModel, @@ -524,7 +525,7 @@ function buildOperation( ? buildRequestBody(deref(doc, operation.requestBody), `${method} ${path}`, doc) : undefined; - const successResponses = buildSuccessResponses(operation, path, doc); + const { successResponses, successResponseHeaders } = buildSuccessResponse(operation, path, doc); const errorResponses = buildErrorResponses(operation, path, doc); const security = resolveOperationSecurity(operation, doc, injectable); @@ -545,6 +546,7 @@ function buildOperation( cookieParams, requestBody, successResponses, + ...(successResponseHeaders === undefined ? {} : { successResponseHeaders }), errorResponses, security, tags: Array.isArray(operation.tags) ? operation.tags.filter((t) => typeof t === 'string') : [], @@ -628,11 +630,14 @@ function buildRequestBody( }; } -function buildSuccessResponses( +function buildSuccessResponse( operation: Oas3Operation, path: string, doc: Oas3Definition -): ResponseBodyModel[] { +): { + successResponses: ResponseBodyModel[]; + successResponseHeaders?: ResponseHeaderModel[]; +} { const responses = operation.responses ?? {}; // An explicit code beats the `2XX` range wildcard (per the spec), which beats `default`. const successCodes = Object.keys(responses).filter((code) => /^2\d\d$/.test(code)); @@ -642,17 +647,24 @@ function buildSuccessResponses( } // Pick the first success response. const code = successCodes[0]; - if (!code) return []; + if (!code) return { successResponses: [] }; const responseRaw = responses[code]; - if (!responseRaw) return []; + if (!responseRaw) return { successResponses: [] }; const response = deref(doc, responseRaw); + const successResponseHeaders = buildResponseHeaders( + response.headers, + `paths.${path}.response.${code}`, + doc + ); const content = response.content; - if (!content) return []; + if (!content) { + return { + successResponses: [], + ...(successResponseHeaders === undefined ? {} : { successResponseHeaders }), + }; + } const status = code === 'default' || code === '2XX' ? code : Number(code); - // Declared response header names (lowercased) — `link`-style pagination fits by them. - const headerNames = Object.keys(response.headers ?? {}).map((name) => name.toLowerCase()); - const headers = headerNames.length > 0 ? headerNames : undefined; const result: ResponseBodyModel[] = []; for (const [contentType, media] of Object.entries(content)) { const schema = schemaFromSlot( @@ -670,10 +682,41 @@ function buildSuccessResponses( schema, status, ...(item === undefined ? {} : { itemSchema: item }), - ...(headers === undefined ? {} : { headers }), }); } - return result; + return { + successResponses: result, + ...(successResponseHeaders === undefined ? {} : { successResponseHeaders }), + }; +} + +type Oas3HeaderShape = { + schema?: Referenced | boolean; + required?: boolean; +}; + +/** Build typed success-response headers; absent when the response declares none. */ +function buildResponseHeaders( + headers: Record | undefined, + location: string, + doc: Oas3Definition +): ResponseHeaderModel[] | undefined { + if (!headers) return undefined; + const result: ResponseHeaderModel[] = []; + for (const [rawName, rawHeader] of Object.entries(headers)) { + const header = deref(doc, rawHeader as Referenced); + const model: ResponseHeaderModel = { + name: rawName.toLowerCase(), + schema: schemaFromSlot( + header.schema === undefined ? undefined : deref(doc, header.schema), + `${location}.headers.${rawName}`, + doc + ), + }; + if (header.required === true) model.required = true; + result.push(model); + } + return result.length > 0 ? result : undefined; } function buildErrorResponses( diff --git a/packages/client-generator/src/intermediate-representation/model.ts b/packages/client-generator/src/intermediate-representation/model.ts index 6dd43f255a..9517263734 100644 --- a/packages/client-generator/src/intermediate-representation/model.ts +++ b/packages/client-generator/src/intermediate-representation/model.ts @@ -132,6 +132,13 @@ export type RequestBodyModel = { description?: string; }; +export type ResponseHeaderModel = { + /** Lowercased wire name (`Header.get` is case-insensitive; link pagination fits by this). */ + name: string; + schema: SchemaModel; + required?: boolean; +}; + export type ResponseBodyModel = { contentType: string; schema: SchemaModel; @@ -146,8 +153,6 @@ export type ResponseBodyModel = { * 3.2 `itemSchema`, e.g. on `text/event-stream`). Absent for ordinary bodies. */ itemSchema?: SchemaModel; - /** Declared response header names, lowercased (`link`-style pagination fits by them). */ - headers?: string[]; }; /** @@ -195,6 +200,8 @@ export type OperationModel = { cookieParams: ParamModel[]; requestBody?: RequestBodyModel; successResponses: ResponseBodyModel[]; + /** Headers declared by the selected success response, including bodyless responses. */ + successResponseHeaders?: ResponseHeaderModel[]; /** * The operation's declared error responses (4xx/5xx, plus `default` when a 2xx * success also exists). Empty when none are declared. Consumed only by diff --git a/packages/client-generator/src/runtime/__tests__/create-client.test.ts b/packages/client-generator/src/runtime/__tests__/create-client.test.ts index 3dc55ead91..54832b5c02 100644 --- a/packages/client-generator/src/runtime/__tests__/create-client.test.ts +++ b/packages/client-generator/src/runtime/__tests__/create-client.test.ts @@ -604,4 +604,160 @@ describe('createClientCore', () => { await client.getOrder({ orderId: '1' }); expect(calls[0].url).toBe('/orders/1'); }); + + it('envelope: true returns data, coerced declared headers, and the raw Response', async () => { + const ops = { + listCustomers: { + id: 'listCustomers', + method: 'GET', + path: '/customers', + responseHeaders: [ + { name: 'pagination-total', key: 'paginationTotal', type: 'number' }, + { name: 'x-flag', key: 'xFlag', type: 'boolean' }, + { name: 'x-label', key: 'xLabel', type: 'string' }, + ], + }, + } satisfies Record; + const { fetchImpl } = spy([ + new Response(JSON.stringify([{ id: 'c1' }]), { + status: 200, + headers: { + 'content-type': 'application/json', + 'Pagination-Total': '128', + 'X-Flag': 'true', + 'X-Label': 'vip', + 'X-Undocumented': 'keep-via-response', + }, + }), + ]); + const client = createClientCore<{ + listCustomers: { + args: Record; + result: Array<{ id: string }>; + headers: { paginationTotal?: number; xFlag?: boolean; xLabel?: string }; + }; + [k: string]: { args: object; result: unknown; headers?: object }; + }>(ops, { serverUrl: 'https://x', fetch: fetchImpl }); + + const envelope = await client.listCustomers({}, { envelope: true }); + expect(envelope.data).toEqual([{ id: 'c1' }]); + expect(envelope.headers).toEqual({ + paginationTotal: 128, + xFlag: true, + xLabel: 'vip', + }); + expect(envelope.response.headers.get('X-Undocumented')).toBe('keep-via-response'); + // Default throw mode is unchanged. + const { fetchImpl: fetch2 } = spy([ + new Response(JSON.stringify([{ id: 'c2' }]), { + status: 200, + headers: { 'content-type': 'application/json', 'Pagination-Total': '1' }, + }), + ]); + type ListOps = { + listCustomers: { + args: Record; + result: Array<{ id: string }>; + headers: { paginationTotal?: number; xFlag?: boolean; xLabel?: string }; + }; + [k: string]: { args: object; result: unknown; headers?: object }; + }; + const client2 = createClientCore(ops, { + serverUrl: 'https://x', + fetch: fetch2, + }); + expect(await client2.listCustomers()).toEqual([{ id: 'c2' }]); + }); + + it('envelope: true omits missing declared headers and ignores the flag in result mode', async () => { + const ops = { + listCustomers: { + id: 'listCustomers', + method: 'GET', + path: '/customers', + responseHeaders: [{ name: 'pagination-total', key: 'paginationTotal', type: 'number' }], + }, + } satisfies Record; + const body = new Response(JSON.stringify([]), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + const { fetchImpl } = spy([body.clone(), body.clone()]); + const throwClient = createClientCore<{ + listCustomers: { + args: Record; + result: unknown[]; + headers: { paginationTotal?: number }; + }; + [k: string]: { args: object; result: unknown; headers?: object }; + }>(ops, { serverUrl: 'https://x', fetch: fetchImpl }); + expect(await throwClient.listCustomers({}, { envelope: true })).toEqual({ + data: [], + headers: {}, + response: expect.any(Response), + }); + + const { fetchImpl: fetchResult } = spy([ + new Response(JSON.stringify([]), { + status: 200, + headers: { 'content-type': 'application/json', 'Pagination-Total': '3' }, + }), + ]); + const resultClient = createClientCore<{ + listCustomers: { args: Record; result: unknown[] }; + [k: string]: { args: object; result: unknown }; + }>(ops, { serverUrl: 'https://x', fetch: fetchResult, errorMode: 'result' }); + const result = await resultClient.listCustomers({}, { envelope: true }); + expect(result).toMatchObject({ data: [], error: undefined }); + expect(result).toHaveProperty('response'); + expect(result).not.toHaveProperty('headers'); + }); + + it('omits blank numeric response headers instead of coercing them to zero', async () => { + const ops = { + listCustomers: { + id: 'listCustomers', + method: 'GET', + path: '/customers', + responseHeaders: [{ name: 'pagination-total', key: 'paginationTotal', type: 'number' }], + }, + } satisfies Record; + const { fetchImpl } = spy([ + new Response(JSON.stringify([{ id: 'c1' }]), { + status: 200, + headers: { 'content-type': 'application/json', 'Pagination-Total': ' ' }, + }), + ]); + const client = createClientCore<{ + listCustomers: { + args: Record; + result: unknown[]; + headers: { paginationTotal?: number }; + }; + [key: string]: { args: object; result: unknown; headers?: object }; + }>(ops, { serverUrl: 'https://x', fetch: fetchImpl }); + + const result = await client.listCustomers({}, { envelope: true }); + + expect(result.headers).toEqual({}); + }); + + it('pagination ignores envelope: true and still resolves pointers against response data', async () => { + const { fetchImpl } = spy([ + jsonOk({ orders: [{ id: 'o1' }], nextCursor: 'c2' }), + jsonOk({ orders: [{ id: 'o2' }] }), + ]); + const client = createClientCore( + OPS, + { serverUrl: 'https://x', fetch: fetchImpl }, + { paginate: { pages: paginatePages, items: paginateItems, pagesByLink, itemsByLink } } + ); + + const seen: string[] = []; + for await (const order of client.listOrders.items({}, { envelope: true })) { + seen.push(order.id); + } + + expect(seen).toEqual(['o1', 'o2']); + }); }); diff --git a/packages/client-generator/src/runtime/__tests__/types.test.ts b/packages/client-generator/src/runtime/__tests__/types.test.ts index 161641b429..c9d90ab511 100644 --- a/packages/client-generator/src/runtime/__tests__/types.test.ts +++ b/packages/client-generator/src/runtime/__tests__/types.test.ts @@ -1,6 +1,7 @@ import type { Client, ClientConfig, + Envelope, Middleware, OperationContext, OperationDescriptor, @@ -24,6 +25,18 @@ interface TestOps { [key: string]: { args: object; result: unknown; kind?: 'sse'; item?: unknown }; } +// Shared by the envelope tests below. +type Customer = { id: string }; +interface EnvelopeOps { + listCustomers: { + args: { params?: { limit?: number } }; + result: Customer[]; + headers: { paginationTotal?: number }; + }; + ping: { args: Record; result: string }; + [key: string]: { args: object; result: unknown; headers?: object }; +} + describe('Client mapped type', () => { it('types methods, optionality, and sse per the Ops entry', () => { // Runtime stub — expectTypeOf reads only the static type, but property access must not throw. @@ -31,12 +44,10 @@ describe('Client mapped type', () => { expectTypeOf(client.requiredArgs).toBeCallableWith({ orderId: 'ord_1' }); expectTypeOf(client.requiredArgs).toBeCallableWith({ orderId: 'ord_1' }, { parseAs: 'json' }); - expectTypeOf(client.requiredArgs).returns.resolves.toEqualTypeOf<{ id: string }>(); // All-optional args → the args object itself is optional. expectTypeOf(client.optionalArgs).toBeCallableWith(); expectTypeOf(client.optionalArgs).toBeCallableWith({ params: { limit: 5 } }); - expectTypeOf(client.optionalArgs).returns.resolves.toEqualTypeOf(); // SSE entries return typed async generators and take SseOptions. expectTypeOf(client.streaming).returns.toEqualTypeOf< @@ -52,6 +63,13 @@ describe('Client mapped type', () => { expectTypeOf(client.auth.apiKey).toBeCallableWith('scheme', 'key'); const _typeOnly = (): void => { + // Default (no init) calls resolve to the plain body. Asserted through calls because + // `.returns` on the generic throw-mode method instantiates `Init` at its constraint, + // which is the (intended) body-or-envelope union. + expectTypeOf(client.requiredArgs({ orderId: 'ord_1' })).resolves.toEqualTypeOf<{ + id: string; + }>(); + expectTypeOf(client.optionalArgs()).resolves.toEqualTypeOf(); // @ts-expect-error required args cannot be omitted void client.requiredArgs(); }; @@ -100,6 +118,7 @@ describe('Client mapped type', () => { listOrders: { args: { params?: { cursor?: string } }; result: Result; + mode: 'result'; item: { id: string }; page: OrderPage; }; @@ -107,6 +126,7 @@ describe('Client mapped type', () => { args: object; result: unknown; kind?: 'sse'; + mode?: 'result'; item?: unknown; page?: unknown; }; @@ -192,4 +212,99 @@ describe('Client mapped type', () => { const init: RequestOptions = { retry: { retries: 1 }, parseAs: 'auto' }; expect(init.parseAs).toBe('auto'); }); + + it('envelope: true returns { data, headers, response }; default stays the body', () => { + const client = { auth: {} } as unknown as Client; + + expectTypeOf(client.listCustomers).toBeCallableWith( + { params: { limit: 1 } }, + { envelope: true } + ); + + const _typeOnly = (): void => { + // Default stays the body; a literal envelope: true narrows to the envelope. + expectTypeOf(client.listCustomers({ params: { limit: 1 } })).resolves.toEqualTypeOf< + Customer[] + >(); + expectTypeOf(client.listCustomers({}, { envelope: true })).resolves.toEqualTypeOf< + Envelope + >(); + // Ops without a headers slot still get an empty typed headers object. + expectTypeOf(client.ping({}, { envelope: true })).resolves.toEqualTypeOf< + Envelope> + >(); + }; + void _typeOnly; + + const init: RequestOptions = { envelope: true }; + expect(init.envelope).toBe(true); + }); + + it('result-mode entries ignore envelope: true without changing their return type', () => { + interface ResultModeOps { + listCustomers: { + args: Record; + result: Result; + mode: 'result'; + headers: { paginationTotal?: number }; + }; + [key: string]: { + args: object; + result: unknown; + mode?: 'result'; + headers?: object; + }; + } + const client = { auth: {} } as unknown as Client; + + const _typeOnly = (): void => { + expectTypeOf(client.listCustomers({}, { envelope: true })).resolves.toEqualTypeOf< + Result + >(); + }; + void _typeOnly; + }); + + it('keeps the plain body for init objects that never mention envelope', () => { + const client = { auth: {} } as unknown as Client; + + const _typeOnly = (): void => { + expectTypeOf(client.listCustomers({}, {})).resolves.toEqualTypeOf(); + expectTypeOf( + client.listCustomers({}, { headers: { 'X-Trace': '1' } }) + ).resolves.toEqualTypeOf(); + expectTypeOf( + client.listCustomers({}, { signal: new AbortController().signal, parseAs: 'json' }) + ).resolves.toEqualTypeOf(); + }; + void _typeOnly; + }); + + it('returns a union when the envelope flag is widened', () => { + const client = { auth: {} } as unknown as Client; + + const _typeOnly = (): void => { + const widened = { envelope: true }; + expectTypeOf(client.listCustomers({}, widened)).resolves.toEqualTypeOf< + Customer[] | Envelope + >(); + + const annotated: RequestOptions = { envelope: true }; + expectTypeOf(client.listCustomers({}, annotated)).resolves.toEqualTypeOf< + Customer[] | Envelope + >(); + + // The tanstack queryFn shape: a spread of possibly-envelope options plus signal. + const spreadCall = (outer?: RequestOptions) => + client.listCustomers({}, { ...outer, signal: new AbortController().signal }); + expectTypeOf(spreadCall).returns.resolves.toEqualTypeOf< + Customer[] | Envelope + >(); + + expectTypeOf(client.listCustomers({}, { envelope: false })).resolves.toEqualTypeOf< + Customer[] + >(); + }; + void _typeOnly; + }); }); diff --git a/packages/client-generator/src/runtime/create-client.ts b/packages/client-generator/src/runtime/create-client.ts index 75b2bcfd67..321d4f7a2d 100644 --- a/packages/client-generator/src/runtime/create-client.ts +++ b/packages/client-generator/src/runtime/create-client.ts @@ -13,6 +13,7 @@ import type { ParseAs, QueryValue, RequestOptions, + ResponseHeaderSpec, SecuritySpec, ServerSentEvent, SseOptions, @@ -210,6 +211,38 @@ async function prepareRequest( return { url, init: mergedInit, body }; } +/** Coerce a single declared response header value; omit when absent or unparsable. */ +function coerceResponseHeader( + raw: string | null, + type: ResponseHeaderSpec['type'] +): string | number | boolean | undefined { + if (raw === null) return undefined; + if (type === 'number') { + if (raw.trim() === '') return undefined; + const value = Number(raw); + return Number.isFinite(value) ? value : undefined; + } + if (type === 'boolean') { + if (raw === 'true') return true; + if (raw === 'false') return false; + return undefined; + } + return raw; +} + +/** Build the camelCase declared-header bag for a throw-mode envelope. */ +function readEnvelopeHeaders( + response: Response, + specs: readonly ResponseHeaderSpec[] | undefined +): Record { + const headers: Record = {}; + for (const spec of specs ?? []) { + const value = coerceResponseHeader(response.headers.get(spec.name), spec.type); + if (value !== undefined) headers[spec.key] = value; + } + return headers; +} + /** One non-SSE call: send, then branch on the configured error mode. */ async function execute( config: ClientConfig, @@ -220,7 +253,8 @@ async function execute( ): Promise { const prepared = await prepareRequest(config, op, args, init, caps); const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] }; - const { parseAs, ...sendInit } = prepared.init; + // `parseAs` / `envelope` are client options, not fetch RequestInit fields. + const { parseAs, envelope, ...sendInit } = prepared.init; const readKind = parseAs ?? kindFor(op); const { response, context } = await send( config, @@ -251,7 +285,15 @@ async function execute( } throw error; } - return parse(response, readKind); + const data = await parse(response, readKind); + if (envelope === true) { + return { + data, + headers: readEnvelopeHeaders(response, op.responseHeaders), + response, + }; + } + return data; } /** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */ @@ -272,9 +314,14 @@ function pageCall( method: (args?: OperationArgs, init?: RequestOptions) => Promise, config: ClientConfig ) { - if (config.errorMode !== 'result') return method; + const callWithoutEnvelope = (args?: OperationArgs, init?: RequestOptions) => { + if (!init || init.envelope === undefined) return method(args, init); + const { envelope: _envelope, ...pageInit } = init; + return method(args, pageInit); + }; + if (config.errorMode !== 'result') return callWithoutEnvelope; return async (args?: OperationArgs, init?: RequestOptions) => { - const envelope = (await method(args, init)) as { + const envelope = (await callWithoutEnvelope(args, init)) as { data: unknown; error: unknown; response: Response; @@ -300,7 +347,7 @@ function pageCall( function linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) { return async (args: OperationArgs = {}, init: RequestOptions = {}) => { const prepared = await prepareRequest(config, op, args, init, caps); - const { parseAs, ...sendInit } = prepared.init; + const { parseAs, envelope: _envelope, ...sendInit } = prepared.init; const readKind = parseAs ?? kindFor(op); const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] }; const { response } = await send( diff --git a/packages/client-generator/src/runtime/types.ts b/packages/client-generator/src/runtime/types.ts index a4ec982f6c..1570899f67 100644 --- a/packages/client-generator/src/runtime/types.ts +++ b/packages/client-generator/src/runtime/types.ts @@ -71,6 +71,18 @@ export type OperationDescriptor = { /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */ security?: readonly (readonly SecuritySpec[])[]; pagination?: PaginationSpec; + /** + * Declared success-response headers for throw-mode `{ envelope: true }`. + * `name` is the lowercased wire name; `key` is the camelCase envelope property. + */ + responseHeaders?: readonly ResponseHeaderSpec[]; +}; + +/** One declared response header the runtime coerces into the envelope `headers` object. */ +export type ResponseHeaderSpec = { + name: string; + key: string; + type: 'string' | 'number' | 'boolean'; }; /** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */ @@ -203,6 +215,19 @@ export type RequestOptions = RequestInit & { /** Per-call idempotency key: a literal key, `true` to generate one, `false` to skip. */ idempotencyKey?: string | boolean | (() => string); parseAs?: ParseAs; + /** + * Throw mode only: return `{ data, headers, response }` instead of the parsed body; + * ignored in result mode. The explicit `| undefined` keeps the wrappers' emitted + * `envelope: undefined` strip legal under `exactOptionalPropertyTypes`. + */ + envelope?: boolean | undefined; +}; + +/** Throw-mode success envelope when `RequestOptions.envelope` is `true`. */ +export type Envelope> = { + data: TData; + headers: THeaders; + response: Response; }; /** Per-call options for an SSE stream; reconnect defaults to true. */ @@ -224,7 +249,17 @@ export type Result = */ export type OpsShape = Record< string, - { args: object; result: unknown; kind?: 'sse'; item?: unknown; page?: unknown } + { + args: object; + result: unknown; + kind?: 'sse'; + item?: unknown; + page?: unknown; + /** Declared success-response headers for `{ envelope: true }` (camelCase keys). */ + headers?: object; + /** Result-mode entries ignore the throw-only `envelope` option. */ + mode?: 'result'; + } >; /** The always-present client members (assigned after the operation loop — they win collisions). */ @@ -283,6 +318,53 @@ type Paginated = 'item' extends keyof Entry */ export type OperationMethodIdentity = { readonly operationId: string }; +/** Declared response-header bag for an Ops entry; empty object when none are declared. */ +type HeadersOf = 'headers' extends keyof Entry + ? NonNullable + : Record; + +/** + * Return type of a throw-mode call: the body by default, `Envelope<…>` for a literal + * `envelope: true`, their union when the flag is statically unknowable (a widened + * `boolean`). The `keyof` presence gate is load-bearing: without it, inits with no + * `envelope` key (`{ headers }`, `{ signal }`) would resolve `TInit['envelope']` + * through `TInit & RequestOptions` to `boolean | undefined` and widen to the union. + */ +export type EnvelopeResult< + TData, + THeaders, + TInit extends RequestOptions | undefined, +> = TInit extends undefined + ? TData + : 'envelope' extends keyof TInit + ? [TInit['envelope' & keyof TInit]] extends [true] + ? Envelope + : [TInit['envelope' & keyof TInit]] extends [false | undefined] + ? TData + : TData | Envelope + : TData; + +/** A one-shot method whose return shape never varies with per-call options. */ +type BodyMethod = + NoRequiredKeys extends true + ? (args?: Entry['args'], init?: RequestOptions) => Promise + : (args: Entry['args'], init?: RequestOptions) => Promise; + +/** + * One-shot (non-SSE) method: default returns the body; `{ envelope: true }` returns + * `{ data, headers, response }` with typed declared headers. + */ +type ThrowMethod = + NoRequiredKeys extends true + ? ( + args?: Entry['args'], + init?: Init + ) => Promise, Init>> + : ( + args: Entry['args'], + init?: Init + ) => Promise, Init>>; + /** The typed instance client: one bound method per operation plus the core members. */ export type Client = { [K in keyof Ops]: Ops[K] extends { kind: 'sse' } @@ -296,9 +378,7 @@ export type Client AsyncGenerator>) & OperationMethodIdentity - : (NoRequiredKeys extends true - ? (args?: Ops[K]['args'], init?: RequestOptions) => Promise - : (args: Ops[K]['args'], init?: RequestOptions) => Promise) & + : (Ops[K] extends { mode: 'result' } ? BodyMethod : ThrowMethod) & OperationMethodIdentity & Paginated; } & ClientCore; diff --git a/tests/e2e/generate-client/base.test.ts b/tests/e2e/generate-client/base.test.ts index dd69e13ae3..fe38760c57 100644 --- a/tests/e2e/generate-client/base.test.ts +++ b/tests/e2e/generate-client/base.test.ts @@ -50,9 +50,9 @@ describe('generate-client base consumer (single-file output)', () => { expect(generated).toContain('// ─── Embedded runtime'); expect(generated).toContain('as const satisfies Record'); expect(generated).toContain('export const { configure, use } = client;'); - expect(generated).toContain('export const getPetById = ('); - expect(generated).toContain('export const getSlowPet = ('); - expect(generated).toContain('export const listPets = ('); + expect(generated).toContain('export const getPetById = (OPERATIONS, { serverUrl: "http://localhost:3102", clientHeader: "redocly-client-generator" });' diff --git a/tests/e2e/generate-client/cafe.snapshot.ts b/tests/e2e/generate-client/cafe.snapshot.ts index 7a9f746fa4..bd1a0620ba 100644 --- a/tests/e2e/generate-client/cafe.snapshot.ts +++ b/tests/e2e/generate-client/cafe.snapshot.ts @@ -875,6 +875,18 @@ export type OperationDescriptor = { /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */ security?: readonly (readonly SecuritySpec[])[]; pagination?: PaginationSpec; + /** + * Declared success-response headers for throw-mode `{ envelope: true }`. + * `name` is the lowercased wire name; `key` is the camelCase envelope property. + */ + responseHeaders?: readonly ResponseHeaderSpec[]; +}; + +/** One declared response header the runtime coerces into the envelope `headers` object. */ +export type ResponseHeaderSpec = { + name: string; + key: string; + type: 'string' | 'number' | 'boolean'; }; /** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */ @@ -1007,6 +1019,19 @@ export type RequestOptions = RequestInit & { /** Per-call idempotency key: a literal key, `true` to generate one, `false` to skip. */ idempotencyKey?: string | boolean | (() => string); parseAs?: ParseAs; + /** + * Throw mode only: return `{ data, headers, response }` instead of the parsed body; + * ignored in result mode. The explicit `| undefined` keeps the wrappers' emitted + * `envelope: undefined` strip legal under `exactOptionalPropertyTypes`. + */ + envelope?: boolean | undefined; +}; + +/** Throw-mode success envelope when `RequestOptions.envelope` is `true`. */ +export type Envelope> = { + data: TData; + headers: THeaders; + response: Response; }; /** Per-call options for an SSE stream; reconnect defaults to true. */ @@ -1028,7 +1053,17 @@ export type Result = */ export type OpsShape = Record< string, - { args: object; result: unknown; kind?: 'sse'; item?: unknown; page?: unknown } + { + args: object; + result: unknown; + kind?: 'sse'; + item?: unknown; + page?: unknown; + /** Declared success-response headers for `{ envelope: true }` (camelCase keys). */ + headers?: object; + /** Result-mode entries ignore the throw-only `envelope` option. */ + mode?: 'result'; + } >; /** The always-present client members (assigned after the operation loop — they win collisions). */ @@ -1087,6 +1122,53 @@ type Paginated = 'item' extends keyof Entry */ export type OperationMethodIdentity = { readonly operationId: string }; +/** Declared response-header bag for an Ops entry; empty object when none are declared. */ +type HeadersOf = 'headers' extends keyof Entry + ? NonNullable + : Record; + +/** + * Return type of a throw-mode call: the body by default, `Envelope<…>` for a literal + * `envelope: true`, their union when the flag is statically unknowable (a widened + * `boolean`). The `keyof` presence gate is load-bearing: without it, inits with no + * `envelope` key (`{ headers }`, `{ signal }`) would resolve `TInit['envelope']` + * through `TInit & RequestOptions` to `boolean | undefined` and widen to the union. + */ +export type EnvelopeResult< + TData, + THeaders, + TInit extends RequestOptions | undefined, +> = TInit extends undefined + ? TData + : 'envelope' extends keyof TInit + ? [TInit['envelope' & keyof TInit]] extends [true] + ? Envelope + : [TInit['envelope' & keyof TInit]] extends [false | undefined] + ? TData + : TData | Envelope + : TData; + +/** A one-shot method whose return shape never varies with per-call options. */ +type BodyMethod = + NoRequiredKeys extends true + ? (args?: Entry['args'], init?: RequestOptions) => Promise + : (args: Entry['args'], init?: RequestOptions) => Promise; + +/** + * One-shot (non-SSE) method: default returns the body; `{ envelope: true }` returns + * `{ data, headers, response }` with typed declared headers. + */ +type ThrowMethod = + NoRequiredKeys extends true + ? ( + args?: Entry['args'], + init?: Init + ) => Promise, Init>> + : ( + args: Entry['args'], + init?: Init + ) => Promise, Init>>; + /** The typed instance client: one bound method per operation plus the core members. */ export type Client = { [K in keyof Ops]: Ops[K] extends { kind: 'sse' } @@ -1100,9 +1182,7 @@ export type Client AsyncGenerator>) & OperationMethodIdentity - : (NoRequiredKeys extends true - ? (args?: Ops[K]['args'], init?: RequestOptions) => Promise - : (args: Ops[K]['args'], init?: RequestOptions) => Promise) & + : (Ops[K] extends { mode: 'result' } ? BodyMethod : ThrowMethod) & OperationMethodIdentity & Paginated; } & ClientCore; @@ -1811,6 +1891,38 @@ async function prepareRequest( return { url, init: mergedInit, body }; } +/** Coerce a single declared response header value; omit when absent or unparsable. */ +function coerceResponseHeader( + raw: string | null, + type: ResponseHeaderSpec['type'] +): string | number | boolean | undefined { + if (raw === null) return undefined; + if (type === 'number') { + if (raw.trim() === '') return undefined; + const value = Number(raw); + return Number.isFinite(value) ? value : undefined; + } + if (type === 'boolean') { + if (raw === 'true') return true; + if (raw === 'false') return false; + return undefined; + } + return raw; +} + +/** Build the camelCase declared-header bag for a throw-mode envelope. */ +function readEnvelopeHeaders( + response: Response, + specs: readonly ResponseHeaderSpec[] | undefined +): Record { + const headers: Record = {}; + for (const spec of specs ?? []) { + const value = coerceResponseHeader(response.headers.get(spec.name), spec.type); + if (value !== undefined) headers[spec.key] = value; + } + return headers; +} + /** One non-SSE call: send, then branch on the configured error mode. */ async function execute( config: ClientConfig, @@ -1821,7 +1933,8 @@ async function execute( ): Promise { const prepared = await prepareRequest(config, op, args, init, caps); const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] }; - const { parseAs, ...sendInit } = prepared.init; + // `parseAs` / `envelope` are client options, not fetch RequestInit fields. + const { parseAs, envelope, ...sendInit } = prepared.init; const readKind = parseAs ?? kindFor(op); const { response, context } = await send( config, @@ -1852,7 +1965,15 @@ async function execute( } throw error; } - return parse(response, readKind); + const data = await parse(response, readKind); + if (envelope === true) { + return { + data, + headers: readEnvelopeHeaders(response, op.responseHeaders), + response, + }; + } + return data; } /** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */ @@ -1873,9 +1994,14 @@ function pageCall( method: (args?: OperationArgs, init?: RequestOptions) => Promise, config: ClientConfig ) { - if (config.errorMode !== 'result') return method; + const callWithoutEnvelope = (args?: OperationArgs, init?: RequestOptions) => { + if (!init || init.envelope === undefined) return method(args, init); + const { envelope: _envelope, ...pageInit } = init; + return method(args, pageInit); + }; + if (config.errorMode !== 'result') return callWithoutEnvelope; return async (args?: OperationArgs, init?: RequestOptions) => { - const envelope = (await method(args, init)) as { + const envelope = (await callWithoutEnvelope(args, init)) as { data: unknown; error: unknown; response: Response; @@ -1901,7 +2027,7 @@ function pageCall( function linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) { return async (args: OperationArgs = {}, init: RequestOptions = {}) => { const prepared = await prepareRequest(config, op, args, init, caps); - const { parseAs, ...sendInit } = prepared.init; + const { parseAs, envelope: _envelope, ...sendInit } = prepared.init; const readKind = parseAs ?? kindFor(op); const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] }; const { response } = await send( @@ -2081,7 +2207,7 @@ export const client = createClient client.auth.apiKey("ApiKey", value); -export const listMenuItems = (params: { +export const listMenuItems = (params: { /** * Use the `endCursor` as a value for the `after` parameter to get the next page. */ @@ -2130,16 +2256,16 @@ export const listMenuItems = (params: { * @maximum 100 */ limit?: number; -} = {}, init: RequestOptions = {}) => client.listMenuItems({ params }, init); -export const createMenuItem = (body: FormData, init: RequestOptions = {}) => client.createMenuItem({ body }, init); -export const deleteMenuItem = (menuItemId: string, init: RequestOptions = {}) => client.deleteMenuItem({ menuItemId }, init); -export const getMenuItemPhoto = (menuItemId: string, params: { +} = {}, init?: I): Promise, I>> => client.listMenuItems({ params }, init) as Promise, I>>; +export const createMenuItem = (body: FormData, init?: I): Promise, I>> => client.createMenuItem({ body }, init) as Promise, I>>; +export const deleteMenuItem = (menuItemId: string, init?: I): Promise, I>> => client.deleteMenuItem({ menuItemId }, init) as Promise, I>>; +export const getMenuItemPhoto = (menuItemId: string, params: { /** * Photo size to retrieve. */ photoSize?: "thumbnail" | "medium" | "large"; -} = {}, init: RequestOptions = {}) => client.getMenuItemPhoto({ menuItemId, params }, init); -export const listOrders = (params: { +} = {}, init?: I): Promise, I>> => client.getMenuItemPhoto({ menuItemId, params }, init) as Promise, I>>; +export const listOrders = (params: { /** * Filters the collection items using space-separated `field:value` pairs. * @@ -2188,20 +2314,20 @@ export const listOrders = (params: { * Returns items where any of the searchable fields contain the search term as a substring. */ search?: string; -} = {}, init: RequestOptions = {}) => client.listOrders({ params }, init); -export const createOrder = (body: Omit, init: RequestOptions = {}) => client.createOrder({ body }, init); -export const getOrderById = (orderId: string, headers: { +} = {}, init?: I): Promise, I>> => client.listOrders({ params }, init) as Promise, I>>; +export const createOrder = (body: Omit, init?: I): Promise, I>> => client.createOrder({ body }, init) as Promise, I>>; +export const getOrderById = (orderId: string, headers: { /** * Optional client-supplied correlation ID, echoed in logs and traces. * @format uuid */ "X-Request-Id"?: string; -} = {}, init: RequestOptions = {}) => client.getOrderById({ orderId, headers }, init); -export const deleteOrder = (orderId: string, init: RequestOptions = {}) => client.deleteOrder({ orderId }, init); -export const updateOrder = (orderId: string, body?: { +} = {}, init?: I): Promise, I>> => client.getOrderById({ orderId, headers }, init) as Promise, I>>; +export const deleteOrder = (orderId: string, init?: I): Promise, I>> => client.deleteOrder({ orderId }, init) as Promise, I>>; +export const updateOrder = (orderId: string, body?: { status: OrderStatus; -}, init: RequestOptions = {}) => client.updateOrder({ orderId, body }, init); -export const listOrderItems = (params: { +}, init?: I): Promise, I>> => client.updateOrder({ orderId, body }, init) as Promise, I>>; +export const listOrderItems = (params: { /** * Filters the collection items using space-separated `field:value` pairs. * @@ -2220,8 +2346,8 @@ export const listOrderItems = (params: { * - `status:placed createdAt:7d` - Combine multiple filters. */ filter?: string; -} = {}, init: RequestOptions = {}) => client.listOrderItems({ params }, init); -export const getRevenue = (params: { +} = {}, init?: I): Promise, I>> => client.listOrderItems({ params }, init) as Promise, I>>; +export const getRevenue = (params: { /** * Start date for the revenue calculation period (ISO 8601 datetime format). * Defaults to 30 days ago if not provided. @@ -2234,5 +2360,5 @@ export const getRevenue = (params: { * @format date */ endDate?: string; -} = {}, init: RequestOptions = {}) => client.getRevenue({ params }, init); -export const registerOAuth2Client = (body: RegisterClientObject, init: RequestOptions = {}) => client.registerOAuth2Client({ body }, init); +} = {}, init?: I): Promise, I>> => client.getRevenue({ params }, init) as Promise, I>>; +export const registerOAuth2Client = (body: RegisterClientObject, init?: I): Promise, I>> => client.registerOAuth2Client({ body }, init) as Promise, I>>; diff --git a/tests/e2e/generate-client/cafe.test.ts b/tests/e2e/generate-client/cafe.test.ts index 5923fc4955..f204116807 100644 --- a/tests/e2e/generate-client/cafe.test.ts +++ b/tests/e2e/generate-client/cafe.test.ts @@ -150,7 +150,8 @@ describe('generate-client end-to-end (cafe.yaml)', () => { 'registerOAuth2Client', ]; for (const name of expected) { - expect(generated).toContain(`export const ${name} = (`); + // Plain arrow, generic envelope-aware arrow, or Object.assign-wrapped (paginated). + expect(generated).toMatch(new RegExp(`export const ${name} = (Object\\.assign\\()?[(<]`)); } }); @@ -178,15 +179,17 @@ describe('generate-client end-to-end (cafe.yaml)', () => { }); test('generated file uses ergonomic signatures (positional path params + params object + body)', () => { - expect(generated).toContain('export const deleteMenuItem = (menuItemId: string,'); - expect(generated).toContain('export const getMenuItemPhoto = (menuItemId: string,'); - expect(generated).toContain('export const updateOrder = (orderId: string,'); - expect(generated).toContain('export const listMenuItems = (params:'); + // Throw-mode flat sugar is generic over `init` (envelope-aware return type). + const sugar = ''; + expect(generated).toContain(`export const deleteMenuItem = ${sugar}(menuItemId: string,`); + expect(generated).toContain(`export const getMenuItemPhoto = ${sugar}(menuItemId: string,`); + expect(generated).toContain(`export const updateOrder = ${sugar}(orderId: string,`); + expect(generated).toContain(`export const listMenuItems = ${sugar}(params:`); // readOnly fields are dropped from the create body (Bucket C). expect(generated).toContain( - 'export const createOrder = (body: Omit,' + `export const createOrder = ${sugar}(body: Omit,` ); - expect(generated).toContain('export const createMenuItem = (body: FormData,'); + expect(generated).toContain(`export const createMenuItem = ${sugar}(body: FormData,`); }); // Named string enums get a runtime const-object companion by default, which the diff --git a/tests/e2e/generate-client/envelope.test.ts b/tests/e2e/generate-client/envelope.test.ts new file mode 100644 index 0000000000..fa51e6a6b3 --- /dev/null +++ b/tests/e2e/generate-client/envelope.test.ts @@ -0,0 +1,92 @@ +// Verifies throw-mode `{ envelope: true }`: declared success-response headers are +// emitted on the descriptor and Ops type, and a consumer that asks for the envelope +// type-checks under strict tsc (body-only default remains). +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { generate, strictTypecheck } from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +describe('generate-client envelope', () => { + it('emits responseHeaders + Ops.headers and accepts envelope: true per call', () => { + const dir = mkdtempSync(join(tmpdir(), 'ots-envelope-')); + const out = join(dir, 'client.ts'); + generate(join(__dirname, 'fixtures', 'response-headers.yaml'), out); + expect(existsSync(out)).toBe(true); + const generated = readFileSync(out, 'utf-8'); + + expect(generated).toContain('{ name: "3d-secure", key: "_3dSecure", type: "boolean" }'); + expect(generated).toContain('{ name: "x-foo", key: "xFoo", type: "number" }'); + expect(generated).toContain('{ name: "x_foo", key: "xFoo_2", type: "string" }'); + expect(generated).toContain('paginationTotal: number'); + expect(generated).toContain('xFlag?: boolean'); + expect(generated).toContain('_3dSecure: boolean'); + expect(generated).toContain('xIds?: string'); + expect(generated).toContain('export type ListCustomersResponseHeaders'); + expect(generated).toContain('export type CreateCustomerResponseHeaders'); + expect(generated).toContain('location: string'); + expect(generated).toMatch(/envelope\?: boolean/); + + writeFileSync( + join(dir, 'usage.ts'), + [ + "import { client, createCustomer, listCustomers } from './client.js';", + '', + 'export async function bodyOnly() {', + ' const rows = await listCustomers();', + ' return rows.map((row) => row.id);', + '}', + '', + // Options that never mention `envelope` keep the plain body type. + 'export async function bodyWithOptions() {', + " const rows = await listCustomers({ headers: { 'X-Trace': '1' } });", + " const viaClientRows = await client.listCustomers({}, { parseAs: 'json' });", + ' return rows.map((row) => row.id).concat(viaClientRows.map((row) => row.id));', + '}', + '', + // Flat sugar: no-input ops take `init` as the first argument. + 'export async function withEnvelope() {', + ' const { data, headers, response } = await listCustomers({ envelope: true });', + ' const total: number = headers.paginationTotal;', + ' const flag: boolean | undefined = headers.xFlag;', + ' const secure: boolean = headers._3dSecure;', + ' const ids: string | undefined = headers.xIds;', + " const raw = response.headers.get('X-Undocumented');", + ' return { rows: data.map((row) => row.id), total, flag, secure, ids, raw };', + '}', + '', + // Instance client uses the grouped args + trailing init shape. + 'export async function viaClient() {', + ' const { data, headers } = await client.listCustomers({}, { envelope: true });', + ' return { data, total: headers.paginationTotal };', + '}', + '', + 'export async function bodylessResponse() {', + " const { data, headers } = await createCustomer('cus_1', { envelope: true });", + ' const nothing: void = data;', + ' const location: string = headers.location;', + ' return { nothing, location };', + '}', + '', + 'export async function widenedEnvelopeOption() {', + ' const options = { envelope: true };', + ' const result = await listCustomers(options);', + " return 'response' in result ? result.data.length : result.length;", + '}', + '', + 'export async function widenedClientOption() {', + ' const options = { envelope: true };', + ' const result = await client.listCustomers({}, options);', + " return 'response' in result ? result.data.length : result.length;", + '}', + '', + ].join('\n'), + 'utf-8' + ); + strictTypecheck(dir, ['client.ts', 'usage.ts']); + rmSync(dir, { recursive: true, force: true }); + }, 60_000); +}); diff --git a/tests/e2e/generate-client/examples/zero-install-quickstart/src/api/client.ts b/tests/e2e/generate-client/examples/zero-install-quickstart/src/api/client.ts index 9a481d9ee3..05934f852f 100644 --- a/tests/e2e/generate-client/examples/zero-install-quickstart/src/api/client.ts +++ b/tests/e2e/generate-client/examples/zero-install-quickstart/src/api/client.ts @@ -210,6 +210,18 @@ export type OperationDescriptor = { /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */ security?: readonly (readonly SecuritySpec[])[]; pagination?: PaginationSpec; + /** + * Declared success-response headers for throw-mode `{ envelope: true }`. + * `name` is the lowercased wire name; `key` is the camelCase envelope property. + */ + responseHeaders?: readonly ResponseHeaderSpec[]; +}; + +/** One declared response header the runtime coerces into the envelope `headers` object. */ +export type ResponseHeaderSpec = { + name: string; + key: string; + type: 'string' | 'number' | 'boolean'; }; /** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */ @@ -342,6 +354,19 @@ export type RequestOptions = RequestInit & { /** Per-call idempotency key: a literal key, `true` to generate one, `false` to skip. */ idempotencyKey?: string | boolean | (() => string); parseAs?: ParseAs; + /** + * Throw mode only: return `{ data, headers, response }` instead of the parsed body; + * ignored in result mode. The explicit `| undefined` keeps the wrappers' emitted + * `envelope: undefined` strip legal under `exactOptionalPropertyTypes`. + */ + envelope?: boolean | undefined; +}; + +/** Throw-mode success envelope when `RequestOptions.envelope` is `true`. */ +export type Envelope> = { + data: TData; + headers: THeaders; + response: Response; }; /** Per-call options for an SSE stream; reconnect defaults to true. */ @@ -363,7 +388,17 @@ export type Result = */ export type OpsShape = Record< string, - { args: object; result: unknown; kind?: 'sse'; item?: unknown; page?: unknown } + { + args: object; + result: unknown; + kind?: 'sse'; + item?: unknown; + page?: unknown; + /** Declared success-response headers for `{ envelope: true }` (camelCase keys). */ + headers?: object; + /** Result-mode entries ignore the throw-only `envelope` option. */ + mode?: 'result'; + } >; /** The always-present client members (assigned after the operation loop — they win collisions). */ @@ -422,6 +457,53 @@ type Paginated = 'item' extends keyof Entry */ export type OperationMethodIdentity = { readonly operationId: string }; +/** Declared response-header bag for an Ops entry; empty object when none are declared. */ +type HeadersOf = 'headers' extends keyof Entry + ? NonNullable + : Record; + +/** + * Return type of a throw-mode call: the body by default, `Envelope<…>` for a literal + * `envelope: true`, their union when the flag is statically unknowable (a widened + * `boolean`). The `keyof` presence gate is load-bearing: without it, inits with no + * `envelope` key (`{ headers }`, `{ signal }`) would resolve `TInit['envelope']` + * through `TInit & RequestOptions` to `boolean | undefined` and widen to the union. + */ +export type EnvelopeResult< + TData, + THeaders, + TInit extends RequestOptions | undefined, +> = TInit extends undefined + ? TData + : 'envelope' extends keyof TInit + ? [TInit['envelope' & keyof TInit]] extends [true] + ? Envelope + : [TInit['envelope' & keyof TInit]] extends [false | undefined] + ? TData + : TData | Envelope + : TData; + +/** A one-shot method whose return shape never varies with per-call options. */ +type BodyMethod = + NoRequiredKeys extends true + ? (args?: Entry['args'], init?: RequestOptions) => Promise + : (args: Entry['args'], init?: RequestOptions) => Promise; + +/** + * One-shot (non-SSE) method: default returns the body; `{ envelope: true }` returns + * `{ data, headers, response }` with typed declared headers. + */ +type ThrowMethod = + NoRequiredKeys extends true + ? ( + args?: Entry['args'], + init?: Init + ) => Promise, Init>> + : ( + args: Entry['args'], + init?: Init + ) => Promise, Init>>; + /** The typed instance client: one bound method per operation plus the core members. */ export type Client = { [K in keyof Ops]: Ops[K] extends { kind: 'sse' } @@ -435,9 +517,7 @@ export type Client AsyncGenerator>) & OperationMethodIdentity - : (NoRequiredKeys extends true - ? (args?: Ops[K]['args'], init?: RequestOptions) => Promise - : (args: Ops[K]['args'], init?: RequestOptions) => Promise) & + : (Ops[K] extends { mode: 'result' } ? BodyMethod : ThrowMethod) & OperationMethodIdentity & Paginated; } & ClientCore; @@ -1083,6 +1163,38 @@ async function prepareRequest( return { url, init: mergedInit, body }; } +/** Coerce a single declared response header value; omit when absent or unparsable. */ +function coerceResponseHeader( + raw: string | null, + type: ResponseHeaderSpec['type'] +): string | number | boolean | undefined { + if (raw === null) return undefined; + if (type === 'number') { + if (raw.trim() === '') return undefined; + const value = Number(raw); + return Number.isFinite(value) ? value : undefined; + } + if (type === 'boolean') { + if (raw === 'true') return true; + if (raw === 'false') return false; + return undefined; + } + return raw; +} + +/** Build the camelCase declared-header bag for a throw-mode envelope. */ +function readEnvelopeHeaders( + response: Response, + specs: readonly ResponseHeaderSpec[] | undefined +): Record { + const headers: Record = {}; + for (const spec of specs ?? []) { + const value = coerceResponseHeader(response.headers.get(spec.name), spec.type); + if (value !== undefined) headers[spec.key] = value; + } + return headers; +} + /** One non-SSE call: send, then branch on the configured error mode. */ async function execute( config: ClientConfig, @@ -1093,7 +1205,8 @@ async function execute( ): Promise { const prepared = await prepareRequest(config, op, args, init, caps); const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] }; - const { parseAs, ...sendInit } = prepared.init; + // `parseAs` / `envelope` are client options, not fetch RequestInit fields. + const { parseAs, envelope, ...sendInit } = prepared.init; const readKind = parseAs ?? kindFor(op); const { response, context } = await send( config, @@ -1124,7 +1237,15 @@ async function execute( } throw error; } - return parse(response, readKind); + const data = await parse(response, readKind); + if (envelope === true) { + return { + data, + headers: readEnvelopeHeaders(response, op.responseHeaders), + response, + }; + } + return data; } /** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */ @@ -1145,9 +1266,14 @@ function pageCall( method: (args?: OperationArgs, init?: RequestOptions) => Promise, config: ClientConfig ) { - if (config.errorMode !== 'result') return method; + const callWithoutEnvelope = (args?: OperationArgs, init?: RequestOptions) => { + if (!init || init.envelope === undefined) return method(args, init); + const { envelope: _envelope, ...pageInit } = init; + return method(args, pageInit); + }; + if (config.errorMode !== 'result') return callWithoutEnvelope; return async (args?: OperationArgs, init?: RequestOptions) => { - const envelope = (await method(args, init)) as { + const envelope = (await callWithoutEnvelope(args, init)) as { data: unknown; error: unknown; response: Response; @@ -1173,7 +1299,7 @@ function pageCall( function linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) { return async (args: OperationArgs = {}, init: RequestOptions = {}) => { const prepared = await prepareRequest(config, op, args, init, caps); - const { parseAs, ...sendInit } = prepared.init; + const { parseAs, envelope: _envelope, ...sendInit } = prepared.init; const readKind = parseAs ?? kindFor(op); const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] }; const { response } = await send( @@ -1351,7 +1477,7 @@ export function createClient< export const client = createClient(OPERATIONS, { serverUrl: "https://api.cafe.redocly.com", clientHeader: "redocly-client-generator" }); export const { configure, use } = client; -export const listMenuItems = (params: { +export const listMenuItems = (params: { /** * Case-insensitive substring match on item names. */ @@ -1362,11 +1488,11 @@ export const listMenuItems = (params: { * @maximum 100 */ limit?: number; -} = {}, init: RequestOptions = {}) => client.listMenuItems({ params }, init); -export const getMenuItemPhoto = (menuItemId: string, params: { +} = {}, init?: I): Promise, I>> => client.listMenuItems({ params }, init) as Promise, I>>; +export const getMenuItemPhoto = (menuItemId: string, params: { /** * Photo size to retrieve. */ photoSize?: "thumbnail" | "medium" | "large"; -} = {}, init: RequestOptions = {}) => client.getMenuItemPhoto({ menuItemId, params }, init); -export const registerOAuth2Client = (body: RegisterClientRequest, init: RequestOptions = {}) => client.registerOAuth2Client({ body }, init); +} = {}, init?: I): Promise, I>> => client.getMenuItemPhoto({ menuItemId, params }, init) as Promise, I>>; +export const registerOAuth2Client = (body: RegisterClientRequest, init?: I): Promise, I>> => client.registerOAuth2Client({ body }, init) as Promise, I>>; diff --git a/tests/e2e/generate-client/fixtures/response-headers.yaml b/tests/e2e/generate-client/fixtures/response-headers.yaml new file mode 100644 index 0000000000..88a944ce0d --- /dev/null +++ b/tests/e2e/generate-client/fixtures/response-headers.yaml @@ -0,0 +1,57 @@ +openapi: 3.1.0 +info: { title: Response Headers API, version: 1.0.0 } +servers: + - { url: https://api.example.com } +paths: + /customers: + get: + operationId: listCustomers + responses: + '200': + description: ok + headers: + Pagination-Total: + required: true + schema: { $ref: '#/components/schemas/Count' } + X-Flag: + schema: { type: boolean } + 3D-Secure: + required: true + schema: { type: boolean } + X-Foo: + schema: { type: integer } + X_Foo: + schema: { type: string } + X-Ids: + schema: + type: array + items: { type: integer } + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/Customer' } + /customers/{id}: + post: + operationId: createCustomer + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '201': + description: created + headers: + Location: + required: true + schema: { type: string } +components: + schemas: + Count: + type: integer + Customer: + type: object + required: [id] + properties: + id: { type: string } diff --git a/tests/e2e/generate-client/pagination.test.ts b/tests/e2e/generate-client/pagination.test.ts index 805f5f0337..c00922da45 100644 --- a/tests/e2e/generate-client/pagination.test.ts +++ b/tests/e2e/generate-client/pagination.test.ts @@ -106,7 +106,9 @@ describe('generate-client pagination consumer', () => { 'getOrder: { id: "getOrder", method: "GET", path: "/orders/{orderId}", params: [{ name: "orderId", in: "path" }] }' ); // …and the flat sugar preserves `.pages`/`.items` via Object.assign. - expect(api).toContain('export const listOrders = Object.assign((params: {'); + expect(api).toContain( + 'export const listOrders = Object.assign((params: {' + ); expect(api).toContain('{ pages: client.listOrders.pages, items: client.listOrders.items });'); expect(api).not.toContain('client.listMenuItems.pages'); // Inline mode embeds paginate.ts (the infinite-loop guard is its fingerprint). diff --git a/tests/e2e/generate-client/path-param-idents.test.ts b/tests/e2e/generate-client/path-param-idents.test.ts index 3b0f84df81..20ccae5e9f 100644 --- a/tests/e2e/generate-client/path-param-idents.test.ts +++ b/tests/e2e/generate-client/path-param-idents.test.ts @@ -97,14 +97,16 @@ describe('non-identifier path parameters', () => { const client = readFileSync(join(dir, 'client.ts'), 'utf-8'); // `widget-id` → safe `widget_id` argument, routed under the quoted wire key. expect(client).toContain( - 'export const getWidget = (widget_id: string, init: RequestOptions = {}) => client.getWidget({ "widget-id": widget_id }, init);' + 'export const getWidget = (widget_id: string, init?: I)' ); + expect(client).toContain('client.getWidget({ "widget-id": widget_id }, init)'); // The descriptor keeps the WIRE name for URL substitution. expect(client).toContain('params: [{ name: "widget-id", in: "path" }]'); // reserved word `new` → `_new` argument, routed under the `new` key. expect(client).toContain( - 'export const getItem = (_new: string, init: RequestOptions = {}) => client.getItem({ new: _new }, init);' + 'export const getItem = (_new: string, init?: I)' ); + expect(client).toContain('client.getItem({ new: _new }, init)'); }); test('the generated client type-checks under strict mode', () => { diff --git a/tests/e2e/generate-client/redocly-config.test.ts b/tests/e2e/generate-client/redocly-config.test.ts index fc0ff5dee9..da7662be32 100644 --- a/tests/e2e/generate-client/redocly-config.test.ts +++ b/tests/e2e/generate-client/redocly-config.test.ts @@ -183,7 +183,8 @@ describe('generate-client redocly.yaml config', () => { // The per-api block applied… expect(entry).toContain("from '@redocly/client-generator'"); // …and the top-level fields did NOT leak in: default throw mode, no zod module. - expect(entry).not.toContain('Result<'); + // (`\b` keeps the throw-mode `EnvelopeResult<` from matching.) + expect(entry).not.toMatch(/\bResult { it('generates a type-checking client from a Swagger 2.0 document', () => { const { generated } = generateAndTypecheck('swagger2.yaml'); - expect(generated).toContain('export const getPet = ('); - expect(generated).toContain('export const createPet = ('); + expect(generated).toContain('export const getPet = { const { generated } = generateAndTypecheck('oas3.2.yaml'); - expect(generated).toContain('export const getThing = ('); + expect(generated).toContain('export const getThing = { it('synthesizes operation names from method+path when operationId is omitted', () => { const { generated } = generateAndTypecheck('no-operationid.yaml'); - expect(generated).toContain('export const getGiftcardsCardId = ('); + expect(generated).toContain('export const getGiftcardsCardId = { [ "import { useMutation, useQuery } from '@tanstack/react-query';", "import { createPetMutation, getPetByIdOptions, listPetsOptions } from './client.tanstack.js';", + "import type { Pet } from './client.js';", 'export function useGetPet(id: number) {', - ' return useQuery(getPetByIdOptions({ id }));', + ' const query = useQuery(getPetByIdOptions({ id }));', + ' // Wrapper inits exclude `envelope`: cached data is the plain body, never an envelope.', + ' const pet: Pet | undefined = query.data;', + ' return pet;', '}', 'export function useListPets() {', " return useQuery(listPetsOptions({ params: { filter: { name: 'rex' } } }));",