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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/client-envelope-response-headers.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 33 additions & 1 deletion docs/@v2/guides/use-generated-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/client-generator/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TData, TError>`.
For **SSE** operations `runtime/sse.ts` provides `sse<T>` — an `async function*` that parses `text/event-stream` frames into `ServerSentEvent<T>` (`{ 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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
6 changes: 4 additions & 2 deletions packages/client-generator/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <I extends RequestOptions | undefined = undefined>(init?: I): Promise<EnvelopeResult<PingResult, Record<string, never>, I>>'
);
expect(contents).toContain('// Generated by @redocly/client-generator');
// bytes should match what we wrote.
Expand Down Expand Up @@ -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 = <I extends RequestOptions | undefined = undefined>('
);
expect(contents).toContain('export type Item');
expect(contents).toContain('serverUrl: "https://api.example.com/v1"');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -72,13 +72,13 @@ export const client = createClient<Ops, OperationId, OperationPath, OperationTag

export const { configure, use } = client;
export const setBearer = client.auth.bearer;
export const getOrder = (orderId: string, params: {
export const getOrder = <I extends RequestOptions | undefined = undefined>(orderId: string, params: {
expand?: string;
} = {}, init: RequestOptions = {}) => client.getOrder({ orderId, params }, init);
} = {}, init?: I): Promise<EnvelopeResult<GetOrderResult, Record<string, never>, I>> => client.getOrder({ orderId, params }, init) as Promise<EnvelopeResult<GetOrderResult, Record<string, never>, 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';
"
`;

Expand All @@ -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 = {};

Expand Down Expand Up @@ -169,16 +169,16 @@ export type OperationTag = Extract<(typeof OPERATIONS)[keyof typeof OPERATIONS],
export const client = createClient<Ops, OperationId, OperationPath, OperationTag>(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(<I extends RequestOptions | undefined = undefined>(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<EnvelopeResult<ListOrdersResult, Record<string, never>, I>> => client.listOrders({ params }, init) as Promise<EnvelopeResult<ListOrdersResult, Record<string, never>, I>>, { pages: client.listOrders.pages, items: client.listOrders.items });
export const getOrder = <I extends RequestOptions | undefined = undefined>(orderId: string, params: {
expand?: string;
} = {}, init: RequestOptions = {}) => client.getOrder({ orderId, params }, init);
} = {}, init?: I): Promise<EnvelopeResult<GetOrderResult, Record<string, never>, I>> => client.getOrder({ orderId, params }, init) as Promise<EnvelopeResult<GetOrderResult, Record<string, never>, 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';
"
`;

Expand Down Expand Up @@ -239,6 +239,7 @@ export type Ops = {
params?: ListOrdersParams;
};
result: Result<ListOrdersResult, unknown>;
mode: "result";
item: Order;
page: ListOrdersResult;
};
Expand All @@ -248,6 +249,7 @@ export type Ops = {
params?: GetOrderParams;
};
result: Result<GetOrderResult, GetOrderError>;
mode: "result";
};
};

Expand Down Expand Up @@ -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';
"
`;
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 = <I extends RequestOptions | undefined = undefined>(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 = <I extends RequestOptions | undefined = undefined>(body: Pet, init?: I): Promise<EnvelopeResult<'
);
expect(output).toContain('EnvelopeResult<CreatePetResult, Record<string, never>, I>');
// SSE sugar takes SseOptions and returns the generator directly (no envelope).
expect(output).toContain(
'export const streamEvents = (init: SseOptions = {}) => client.streamEvents({}, init);'
);
Expand All @@ -177,16 +179,17 @@ 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 = <I extends RequestOptions | undefined = undefined>(init?: I): Promise<EnvelopeResult<'
);
expect(output).toContain('=> client.configure_2({}, init) as Promise<');
});

it('re-exports the public surface', () => {
expect(output).toContain(
"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';"
);
});

Expand All @@ -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 = <I extends RequestOptions | undefined = undefined>(pet_id: string, init?: I): Promise<EnvelopeResult<'
);
expect(out).toContain('=> client.getPet({ "pet-id": pet_id }, init) as Promise<');
expect(out).toContain('"pet-id": string;'); // Ops args + Variables alias, wire-keyed
});

Expand All @@ -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', () => {
Expand All @@ -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 } } };'
Expand All @@ -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';"
);
});

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(<I extends RequestOptions | undefined = undefined>(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 = <I extends RequestOptions | undefined = undefined>(orderId: string, params: {'
);
expect(out).not.toContain('Object.assign((orderId');
});

Expand Down
Loading
Loading