feat: openapi typescript client#2885
Conversation
…enAPI client Add `@redocly/openapi-typescript` and the `redocly generate-client` command: generate a typed TypeScript client from an OpenAPI description. The emitted client has zero runtime dependencies (web-standard fetch/AbortController/ URLSearchParams) and is produced via the TypeScript compiler AST, so output is correct by construction; `typescript` is the only peer dependency. Input: OpenAPI 3.0/3.1/3.2.0 + Swagger 2.0 (normalized to 3.x); file, URL, or a redocly.yaml `apis:` alias; operationId synthesized when absent. Output: single / split / tags / tags-split layouts; `functions` or `service-class` facade (per-instance config + credentials); flat or grouped argument styles. Types: inline types; enums as unions or runtime const objects; discriminated- union `is<Member>()` guards; `<Op>Result/Error/Params/Body/Headers/Variables` aliases with collision suppression; JSDoc from validation keywords; optional `Date` typing; typed multipart bodies (binary → Blob) auto-serialized to FormData. Runtime: setBaseUrl + typed ClientConfig; composable middleware (onRequest/ onResponse/onError); opt-in abort-aware retries (backoff, jitter, Retry-After, custom retryOn); per-call parseAs; OpenAPI query-serialization styles; `--error-mode result` discriminated returns; minification-safe OPERATIONS map; typed Server-Sent Events (async iterators, auto-reconnect, OAS 3.2 itemSchema). Auth: Basic / Bearer / apiKey (header, query, cookie) from securitySchemes, async token providers, and per-instance credentials via ClientConfig.auth. Generators (--generators): sdk (default), zod, tanstack-query (react/vue/svelte/ solid), swr, transformers, mock (MSW handlers + baked or faker data, seedable), plus an experimental custom-generator plugin API (@redocly/openapi-typescript/ plugin) with dual loading (inline + import specifier) and a validated compatibility contract. Each generator declares requires/facades/errorModes/ dateTypes, validated up front. Configuration via CLI flags, a redocly.yaml `x-openapi-typescript` block, or a defineConfig file; plus `--watch`. Hardened: document-derived names coerced to safe unique identifiers, comment text escaped, bounded SSE reader. Architecture, ADRs (0001-0012), and runnable examples included.
🦋 Changeset detectedLatest commit: 698d123 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Co-authored-by: Jacek Łękawa <164185257+JLekawa@users.noreply.github.com>
…ing docs Switch the runnable examples and the cafe e2e fixture/snapshot to the spec's `servers[0].url` (api.cafe.redocly.com), regenerated with the current generator. Demonstrate middleware in the fetch-functions example via `onResponse` so it isn't blocked by the demo API's CORS preflight (it doesn't allow a custom `X-Request-Id` request header). Add a "Testing the generated client" section to the README (Node / browser-CORS / MSW mocks).
Resolve conflicts in package.json, package-lock.json, packages/cli/package.json, tsconfig.json, tsconfig.build.json, and vitest.config.ts. - Adopt main's esbuild-bundled CLI build; add @redocly/openapi-typescript to packages/cli devDependencies so it bundles alongside openapi-core/respect-core. - Bump openapi-typescript's @redocly/openapi-core dependency 2.31.4 -> 2.34.0 to match the workspace, so npm symlinks the workspace package instead of a nested copy (the mismatch produced divergent Config type identities). - Extend the CLI bundle banner to shim __filename/__dirname (via var, to coexist with deps that self-declare them) so the bundled typescript compiler used by generate-client runs in ESM scope. - Keep the per-glob 100% coverage threshold for openapi-typescript alongside main's repo-wide branches:73.
Performance Benchmark (Lower is Faster)
|
…MD009 Three prose lines had a stray single trailing space (lines 644, 651, 939), which markdownlint MD009 rejects (expects 0 or 2). Verified clean with markdownlint-cli2 v0.22.0 against the repo's .markdownlint.yaml.
The vale job used reviewdog with filter_mode: file, which fetches the PR diff to scope findings to changed files. GitHub's diff API caps at 20000 lines, so large PRs (this one adds ~54k lines) return 406 and reviewdog fails on the post step — not on any actual vale finding. filter_mode: nofilter skips the diff fetch and lints the full files directly. Verified the committed docs are clean (0 errors/warnings/ suggestions across 320 files with vale 3.15.1), so nofilter adds no noise and the error-level gate still holds.
The consumer harnesses (base/cafe/sse) have tracked index*.ts that import a generated `./api.js`. The repo-wide `tsc --noEmit` includes tests/**/*.ts, so it typechecks those imports — but `api.ts` was gitignored and only created when the e2e suite ran, so a fresh checkout (CI) failed with TS2307. The harnesses already expected api.ts present for typecheck (see the note in sse.runtime.test.ts); gitignoring it was the gap. generate-client output is byte-deterministic for these fixtures (fixed ports, fixed specs — verified by regenerating and diffing), so commit the files instead of touching tsconfig. The e2e suite still regenerates them each run, producing identical content (verified: no drift after running base.test.ts).
…apshots The branch added `react`/`react-dom` `^18.2.0` to the root devDependencies (main has neither — it resolves react 19.2.7 via packages/cli's `^17 || ^18.2.0 || ^19.2.7` range). That root `^18.2.0` cap forced npm to hoist react 18.3.1 for the whole workspace, so build-docs rendered React 18's `useId` format (`tab:R9pq:0`) instead of the React 19 format (`tab_R_9pq_0`) the committed redoc-static snapshots were generated with — failing build-docs.test.ts on CI. Bump root react/react-dom to `^19.2.0` so npm resolves 19.2.7, matching main. Verified: build-docs.test.ts (7/7) and the react-19 consumers (tanstack-query.runtime, swr) all pass.
filter_mode: nofilter alone wasn't enough — the github-pr-annotations reporter still fetches the PR diff to position comments, and GitHub caps that diff at 20000 lines, so this 54k-line PR gets a 406 and reviewdog exits 1 (on the diff fetch, not on any vale finding). Switch reporter to `local`: reviewdog prints findings to the job log and exits non-zero only on vale errors, with no GitHub API call and therefore no diff to fetch. The gate still holds (committed docs verified: 0 vale errors across 320 files). Trade-off: findings show in the Actions log rather than as inline PR annotations — which a 20k-line-capped diff can't render on a PR this size anyway.
tatomyr
left a comment
There was a problem hiding this comment.
Checked a couple of root files. Haven't checked any actual implementation yet.
| "exclude": ["node_modules"], | ||
| "include": [ |
There was a problem hiding this comment.
updated a bit, but we have to exclude examples since they include react code, which is not covered in this tsconfig...
There was a problem hiding this comment.
Let's discuss offline whether we need React examples.
Several e2e suites each spawn a `tsc --noEmit` gate, a live server, and multiple `tsx` consumers (~1 GB apiece). Unbounded on the 4-core CI runner they pile up past its memory ceiling and the agent is OOM-killed, which GitHub surfaces as a "The operation was canceled" E2E step. Run at most two test files at once, which stays under the ceiling.
…geCall 204 fix These generated fixtures embed the inline client runtime, including `pageCall`. The 204/void fix changed that runtime but only the byte-asserted `examples/` fixtures were regenerated; these two consumer fixtures are not byte-checked, so they were left one fix stale. Regenerate them to match.
…fig blocks mergeConfig shallow-merged the top-level and per-API `client` blocks, so a per-API `pagination` override carrying only `operations` (or any partial field) replaced the whole top-level object, dropping shared convention fields like `style`, `cursorParam`, and `items`. Layer the `pagination` block instead: convention scalars survive a partial override, and `operations` merge by id.
| '\n' + | ||
| `❌ Failed to generate TypeScript client${job.name ? ` for ${job.name}` : ''}.\n ${message}\n` + | ||
| ' Check the API description file path and that the OpenAPI document is valid.' | ||
| ); |
There was a problem hiding this comment.
Catch-all error message misleads for non-file errors
Medium Severity
The catch block wraps every error from generateClient with a trailing hint: "Check the API description file path and that the OpenAPI document is valid." This message is misleading for errors unrelated to file loading — such as generator compatibility failures, pagination validation errors, NotSupportedError from an invalid setup path, or a custom-generator plugin that fails to import. The misleading suffix sends the user debugging in the wrong direction.
Reviewed by Cursor Bugbot for commit 6787759. Configure here.
Running the whole e2e suite in one step was cancelled mid-run by the GitHub Actions service once the generate-client suites grew past ~28 — the runner stayed healthy the whole time (no memory/disk/CPU/fd exhaustion, job token still renewing), so this was a service-side cancellation of an oversized run, not a failing test. Split e2e into a 2-shard matrix (`--shard`) so each runner carries roughly half the suites, well under that threshold.
26183bc to
be92b1e
Compare
| // A transport failure (connect/DNS/reset) when opening the request, or a mid-stream | ||
| // read error, is a dropped connection: fall through to backoff/reconnect when enabled. | ||
| if (!reconnect) throw error; | ||
| } |
There was a problem hiding this comment.
SSE JSON errors reconnect forever
Medium Severity
When an SSE event uses JSON data, parseSseFrame calls JSON.parse without handling failures. A syntax error is not an ApiError, so the outer sse loop treats it like a dropped connection and reconnects when reconnect is enabled, which can repeat indefinitely on a stable bad payload instead of surfacing a parse error.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit be92b1e. Configure here.
| ) | ||
| .command( | ||
| 'generate-client [api]', | ||
| 'Generate a TypeScript client from an OpenAPI description.', |
There was a problem hiding this comment.
| 'Generate a TypeScript client from an OpenAPI description.', | |
| 'Generate a TypeScript client from an OpenAPI description [experimental].', |
tatomyr
left a comment
There was a problem hiding this comment.
It's hard to review this at once. Will make another pass later.
| @@ -308,6 +308,7 @@ const createConfigRoot = (nodeTypes: Record<string, NodeType>): NodeType => ({ | |||
| ...nodeTypes.rootRedoclyConfigSchema.properties, | |||
| ...ConfigGovernance.properties, | |||
| apis: 'ConfigApis', // Override apis with internal format | |||
| client: 'Client', // generate-client shared defaults | |||
There was a problem hiding this comment.
Please avoid comments. If the name is not descriptive enough -- let's change it.
There was a problem hiding this comment.
removed comment
|
|
||
| - name: E2E Tests | ||
| run: npm run e2e | ||
| e2e: |
There was a problem hiding this comment.
What is the reason for this change? The e2e job isn't a required check in the repo's settings, and it would be possible to merge a PR even with the job failing.
There was a problem hiding this comment.
We had canceled GH job without any reason. I tried to do deep research, this is a summary:
Tets started failing after we added more tests, there is no specific test impacting this. Only number of tests...
AI made some research:
What I ruled out with live in-job instrumentation (every metric healthy at the moment of cancel): RAM (~14 GB free), disk (~90 GB free), CPU/load, processes (~190), threads (~480), file descriptors (~2 500 of 65 536), sockets/TIME-WAIT, inodes (18 M free), kernel OOM (none). Also ruled out: parallel contention (serial --maxWorkers=1 still cancels, just later), network, log volume, and any single test (all pass locally and individually).
The decisive piece — from the runner's own _diag logs: the job token renews successfully every 60 s and console uploads succeed (1/1) right up to the cancel. So the runner stays healthy and connected; GitHub's service is deliberately cancelling a healthy job. The exact service-side reason isn't observable from inside — when the service cancels, it tears the step down and stops accepting console output, so streamed diag lines can't upload. That last mile needs GitHub support or org runner-group admin visibility
| @@ -24,6 +25,13 @@ const configExtension: { [key: string]: ViteUserConfig } = { | |||
| functions: 73, | |||
| statements: 69, | |||
| branches: 61, | |||
| // The hand-written client runtime is held to 100% (aggregate 100% ⇒ every file 100%). | |||
| 'packages/client-generator/src/runtime/**/*.ts': { | |||
There was a problem hiding this comment.
What's the point of having separate thresholds for this package?
There was a problem hiding this comment.
ah, will remove it, since AI added it back 😞
|
|
||
| Added an **experimental** `generate-client` command that generates a typed TypeScript client from an OpenAPI description — auth, retries, middleware, typed SSE streaming, and multipart out of the box, built on a hand-written, directly-tested runtime. | ||
|
|
||
| Generated clients are typed operation descriptors (`OPERATIONS … satisfies Record<string, OperationDescriptor>`) plus an `Ops` type, wired into a `createClient` instance. Every generated module exports both call styles: the `client` instance (grouped-args methods plus `configure`/`use`/`auth`) and free-function one-liners (`--args-style` shapes them), and re-exports `createClient` for additional per-tenant instances. |
There was a problem hiding this comment.
Let's leave a link to the docs instead.
There was a problem hiding this comment.
I can still see the huge changeset. Please focus of what's added leaving behind the configuration intricancies.
|
|
||
| ## Configuration | ||
|
|
||
| Instead of passing flags every time, keep the settings in `redocly.yaml` under a top-level `client` block and per-API `apis.<name>.client` / `clientOutput`. Settings resolve **top-level `client` → per-API `client` → CLI flags**. See [`client` configuration](../configuration/reference/client.md) for the full reference. |
There was a problem hiding this comment.
Settings resolve top-level
client→ per-APIclient→ CLI flags.
this doesn't seem right. CLI args take precedence over config, and the config is resolved a bit differently (apis-level options override the root ones, as described here, or override the root ones completely -- depends on what we decide).
| apis: | ||
| cafe: | ||
| root: ./openapi.yaml # the input | ||
| clientOutput: ./src/api/client.ts # optional; defaults to `cafe.client.ts` |
There was a problem hiding this comment.
How does it know the default?
There was a problem hiding this comment.
the default output filename is derived from the API's alias (the key under apis:). For the api aliased cafe, when clientOutput is omitted the client is written to .client.ts (i.e. cafe.client.ts) in the redocly.yaml directory. In the command that's fileNameFor(job.name) → ${name}.client.ts where name is the alias.
There was a problem hiding this comment.
changed to clientOutput: ./src/api/client.ts # optional; defaults to <api-name>.client.ts (here cafe.client.ts), this makes that explicit.
|
|
||
| ## Precedence | ||
|
|
||
| Settings resolve **top-level `client` → per-API `client` → CLI flags** (later wins). A plain file-path invocation ignores `apis:` and uses only the top-level `client`. |
There was a problem hiding this comment.
This section should be only about the config application. No need to mention the full resolution path as already have it here.
| `\n❌ --output can't target multiple APIs. Set \`clientOutput\` under each api in redocly.yaml, or pass a single <api>.\n` | ||
| ); | ||
| } | ||
| for (const [name, apiCfg] of Object.entries(apisCfg)) { |
There was a problem hiding this comment.
It feels like you should have been utilising getFallbackApisOrExit to apply the same approach other commands use instead (like in the bundle command, for example).
… pagination examples
`generateClient` is a named export, but the custom-generator snippet imported it as a
default — corrected to `import { generateClient }`. Also add the `pagination`,
`custom-pagination`, and `nested-facade` examples to the Examples list, which had gone stale.
| output: outputPath, | ||
| config, | ||
| configDir, | ||
| }); |
There was a problem hiding this comment.
Per-api config not applied
Medium Severity
generateClient is always called with the root config, while other API commands resolve per-alias settings via config.forAlias. Per-API extends, resolve, decorators, and related apis overrides therefore do not apply when bundling the OpenAPI input, so generated clients can diverge from what bundle or lint produce for the same alias.
Reviewed by Cursor Bugbot for commit 0ef1ba1. Configure here.
| const contentType = response.headers.get('content-type') ?? ''; | ||
| if (contentType.toLowerCase().includes('json')) return response.json(); | ||
| if (contentType.startsWith('text/')) return response.text(); | ||
| return response.blob(); |
There was a problem hiding this comment.
Text content-type case ignored
Medium Severity
Auto response decoding lowercases the content type only for the JSON check, then compares text/* with a case-sensitive startsWith. A Text/Plain or similar header therefore falls through to blob() instead of text(), so callers get a Blob where a string is expected.
Reviewed by Cursor Bugbot for commit 45887e6. Configure here.
| // `errorMode` is fixed at generate time (it shapes the static types); flipping it at | ||
| // runtime would silently desync return shapes from `Client<Ops>`, so it is ignored. | ||
| const { errorMode: _fixed, ...rest } = next; | ||
| Object.assign(config, rest); |
There was a problem hiding this comment.
Configure replaces entire auth object
Medium Severity
configure() shallow-assigns into the live config, so configure({ auth: { bearer: … } }) replaces the whole auth object. Credentials previously set via client.auth.apiKey / basic are dropped, unlike the auth setters which merge into existing credentials.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 45887e6. Configure here.
Runtime correctness:
- SSE: throw a distinct SseParseError on malformed JSON `data` so a stable bad
payload surfaces instead of reconnecting forever; detect frame boundaries with
any two consecutive line terminators (mixed CR/LF), not just matching pairs.
- Security: apply exactly one OR-alternative (the first fully-injectable
requirement object) so "bearer OR apiKey" never sends both credentials.
- Response decoding: match the content-type case-insensitively so `Text/Plain`
reads as text, not a Blob.
- configure({ auth }) merges into existing credentials (like the auth setters)
instead of replacing the whole auth object.
- mergeConfig unions pagination `exclude` across config layers (additive, like
`operations`) rather than replacing it.
CLI & tooling:
- Drop the misleading "check the file path" suffix from the generate-client
failure message (it hid generator/pagination/setup errors).
- Remove the racy client-generator runtime lint-staged glob; runtime-sources is
regenerated on install (`prepare`) and guarded by the drift test.
Docs & config:
- Correct the settings-resolution wording (CLI flags win; per-API overrides the
top-level field by field), trim the config-reference precedence section, and
clarify the clientOutput default.
- Shorten the changeset intro to a docs link.
- Drop redundant inline comments in the config node types.
…s config - Replace the comma-separated `--generators` flag with an idiomatic repeatable array option — `--generator sdk --generator zod` (like `--skip-rule`), dropping the custom comma coerce. The `generators` config field is unchanged. Updated the compatibility-error hint, the e2e suites, and the docs. - Resolve each api's config with `config.forAlias(alias)` before generating, so the OpenAPI input is bundled with that api's `extends` / `resolve` / decorators (matching `bundle`/`lint`). Previously the root config was always used, so per-API overrides never reached the generated client.
| const prepared = await prepareRequest(config, op, args, init, caps); | ||
| const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] }; | ||
| yield* stream(config, opCtx, prepared.url, prepared.init, op.sseDataKind ?? 'text'); | ||
| })(); |
There was a problem hiding this comment.
SSE auth stale on reconnect
Medium Severity
TokenProvider auth is resolved once in prepareRequest before the SSE generator starts, then reconnects reuse those frozen headers and URL query credentials. A refresh-style provider therefore keeps sending the expired token after a drop, while config.headers() still runs per send attempt — so long-lived streams can fail with 401 after reconnect instead of picking up a fresh credential.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit f649e42. Configure here.
…w-comment changes - Update the two validateGenerators unit assertions to the repeatable `--generator` hint (missed in the flag rename). - Regenerate the cafe e2e snapshot for the runtime changes (case-insensitive content-type, configure() auth merge). - Fix a lingering `--generators` reference in the --date-type help text and ARCHITECTURE.md.
…untime changes The base/cafe/pagination/sse consumer clients embed the inline runtime, so refresh them for the SSE, content-type, and configure()-auth fixes. (The package-runtime and tanstack consumers import the runtime, so they're unaffected.)
| yield page; | ||
| const pageItems = resolvePointer(page, spec.items); | ||
| if (!Array.isArray(pageItems) || pageItems.length === 0) return; | ||
| position += spec.style === 'page' ? 1 : pageItems.length; |
There was a problem hiding this comment.
Offset pagination string concatenation
Medium Severity
Offset/page iteration advances with += on a value taken from params without coercing to a number. QueryValue allows strings, so a string page/offset (common from URL or form input) concatenates instead of adding, producing wrong query values and potentially never-ending iteration.
Reviewed by Cursor Bugbot for commit 4f9bde4. Configure here.
| const value = await resolveToken(provider); | ||
| if (scheme.in === 'header') headers[scheme.name] = value; | ||
| else if (scheme.in === 'query') query[scheme.name] = value; | ||
| else cookies.push(`${scheme.name}=${value}`); |
There was a problem hiding this comment.
Cookie auth values left unencoded
Medium Severity
Cookie apiKey credentials are written into the Cookie header with raw interpolation. Keys containing ;, =, spaces, or other reserved cookie characters produce a malformed header, so authentication fails silently for otherwise valid credentials.
Reviewed by Cursor Bugbot for commit 4f9bde4. Configure here.
…ffset, refresh SSE auth on reconnect
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
There are 10 total unresolved issues (including 8 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 698d123. Configure here.
| const basic = config.auth?.basic; | ||
| if (basic !== undefined) { | ||
| headers.Authorization = `Basic ${btoa(`${basic.username}:${basic.password}`)}`; | ||
| } |
There was a problem hiding this comment.
Basic auth breaks on Unicode
High Severity
resolveAuth builds the Basic header with btoa on the raw username:password string. btoa only accepts Latin-1, so non-ASCII credentials throw InvalidCharacterError before the request is sent, and the same failure is baked into inline clients.
Reviewed by Cursor Bugbot for commit 698d123. Configure here.
| ...authed.headers, | ||
| ...stringHeaders(headers), | ||
| ...(init.headers as Record<string, string> | undefined), | ||
| }, |
There was a problem hiding this comment.
Headers objects are dropped
High Severity
Per-call and config headers are merged with object spread while casting to a plain record. RequestInit.headers may be a Headers instance or a string[][], and spreading those values contributes no entries, so caller headers are silently ignored.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 698d123. Configure here.
| // Avoid errors when external dependencies use CJS syntax. The bundled | ||
| // `typescript` compiler (used by generate-client) references `require`, | ||
| // `__filename`, and `__dirname` at runtime — none exist in ESM scope. Shim | ||
| // them per output file. `var` (not `const`) so these coexist with the | ||
| // self-declared `var __dirname` some deps already inline for ESM compat. |
There was a problem hiding this comment.
The original comment was clear enough and easy to read.
| 'query-framework'?: 'react' | 'vue' | 'svelte' | 'solid'; | ||
| 'mock-data'?: 'baked' | 'faker'; | ||
| 'mock-seed'?: number; | ||
| // Repeated `--generator` flags: built-in names, inline custom-generator names, or plugin |
There was a problem hiding this comment.
Let's add that to the docs / yargs descriptions instead of comments.
There was a problem hiding this comment.
Overall, avoid comments in this file. It belongs to the CLI package, it represents the entry point and the CLI interface. The code here should be easy to read, and it's should follow the same concepts as the other commands (like lint, bundle, etc.).
| * `//host` values are rejected too: the value is inlined as the client's default fetch base. | ||
| */ | ||
| function isValidServerUrl(value: string): boolean { | ||
| if (value.startsWith('/')) return !value.startsWith('//'); // root-relative, not protocol-relative |
There was a problem hiding this comment.
It's a bit cryptic to read. I believe it's better to explicitly return every separate case.
| }; | ||
|
|
||
| const perApiJob = (name: string): Job => { | ||
| const apiCfg = apisCfg[name]; |
There was a problem hiding this comment.
You should use config.forAlias(alias) like in other commands (bundle, lint, etc.).
| @@ -345,6 +348,55 @@ const ConfigHTTP: NodeType = { | |||
| }, | |||
| }; | |||
|
|
|||
| // `generate-client` settings. Shared defaults live under the top-level `client` key; | |||
There was a problem hiding this comment.
These comments are needless. Please remove them.
| @@ -272,18 +272,34 @@ export type ResolveConfig = { | |||
|
|
|||
| export type Telemetry = 'on' | 'off'; | |||
|
|
|||
| /** | |||
There was a problem hiding this comment.
Let's avoid excessive comments as they make the code harder to read. And they appear to be needless.
| type: 'string', | ||
| }) | ||
| .options({ | ||
| output: { |
There was a problem hiding this comment.
Should we have this validation must end in .ts added earlier, like on arguments level ?


What/Why/How?
Adds
@redocly/client-generatorand the experimentalredocly generate-clientcommand: generate a fully-typed TypeScript client from an OpenAPI description — auth, retries, middleware, typed streaming, auto-pagination, and mocks out of the box. Output is built via the TypeScript compiler AST (not string templates), so it is correct by construction;typescriptis the only peer dependency (the CLI provides it).The architecture: one client, two distributions
Generated clients are typed operation descriptors (
OPERATIONS … satisfies Record<string, OperationDescriptor>) plus anOpstype, wired into acreateClientinstance backed by a hand-written, directly-tested runtime (100% coverage). The--runtimeoption picks how that runtime ships:inline(default) — one self-contained file, zero runtime dependencies (web-standardfetch/AbortController/URLSearchParams), embedding only the runtime modules the API actually uses (no SSE code for a spec without streams).package— the generated file imports the engine from@redocly/client-generator, so runtime fixes reach every consumer vianpm updatewith no regeneration; the emittedsatisfiesclause doubles as a build-time version-skew guard.Application code is identical in both modes. Existing tools force a choice between types-only (hand-write every fetch/auth/retry) or a client with a permanent runtime dependency — this makes that a per-project knob instead.
Surface
--output-mode single(default) orsplit(entry +<name>.schemas.ts). Every module exports both call styles — theclientinstance (grouped args +configure/use/auth) and flat free functions (--args-styleshapes them) — pluscreateClientfor per-tenant/multi-instance use.is<Member>()guards,<Op>Result/Params/Body/Variablesaliases, optionalDatetyping, typed multipart (binary →Blob).securitySchemes(async token providers, per-instance credentials, generatedsetBearer/setApiKey*sugar); composable middleware wherectx.operation.{id,path,tags}are literal unions from your spec — a misspelled operationId fails compilation; opt-in abort-aware retries (backoff + jitter +Retry-After); per-callparseAs; OpenAPI query-serialization styles;--error-mode resultfor{ data, error, response }; typed Server-Sent Events (auto-reconnect,Last-Event-ID, OAS 3.2itemSchema).client.paginationconvention block inredocly.yaml(or thex-paginationextension) gives paginated operations typed.pages()/.items()async iterators (cursor/offset/pagestyles), with item types resolved statically from the response schema. The convention applies only where it structurally fits (verified against params and the response schema); an explicit rule that doesn't fit fails generation with a per-operation error.--setupbakes adefineClientSetup({ config, middleware })module into the client, layered between spec defaults and appconfigure().--generators):sdk(default),zod,tanstack-query(React/Vue/Svelte/Solid),swr,transformers,mock(MSW, baked or faker) — each emits its own file and adds no dependency to the client — plus an experimentaldefineGeneratorplugin API for custom emitters.clientblock inredocly.yaml(shared defaults + per-API overrides underapis.<name>.client); programmaticgenerateClient(...)API.globalThis-safe embedded types, bounded SSE reader, restricted server-URL schemes, output-path and setup-path validation.Size, honestly
~91k added lines across 433 files, but the implementation is a small fraction:
packages/client-generator/src, excl. tests)Reference
Testing
npm testgreen (compile + typecheck + unit + e2e); 100% per-file coverage on the package.tsc, and runs them against a mock server.packages/client-generator/examples/.Screenshots (optional)
Check yourself
Security
Note
Medium Risk
Large new surface area (codegen + runtime + CLI) with experimental API stability, though it is heavily tested and isolated behind a new command and package.
Overview
Introduces
@redocly/client-generatorand the experimentalredocly generate-clientcommand to emit typed TypeScript clients from OpenAPI (3.x and Swagger 2.0), with optional companion outputs (Zod, TanStack Query, SWR, transformers, MSW mocks) and adefineGeneratorplugin hook.The CLI wires
handleGenerateClient: resolve jobs fromredocly.yaml(client/apis.<name>.client/clientOutput) or a path/alias, merge flags over config, bundle via the same per-alias config as lint/bundle, then callgenerateClient. Notable flags include--runtime(inlinezero-dep embed vspackageimport),--output-mode(single/split),--setup, and repeated--generator.@redocly/openapi-coregains a loosely typed top-levelclientblock and per-APIclientOutputin the config schema. CI splits e2e into two Vitest shards after the generate-client suites grew large. Docs, README, changeset, formatter/linter ignores for committed generated e2e artifacts, and CLI build banner shims for bundled TypeScript complete the integration.Reviewed by Cursor Bugbot for commit 698d123. Bugbot is set up for automated code reviews on this repo. Configure here.