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: 00d2e49 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.
|
📦 A new experimental 🧪 version v0.0.0-snapshot.1784806880 of Redocly CLI has been published for testing. Install with NPM: npm install @redocly/cli@0.0.0-snapshot.1784806880 |
|
name collision openapi: 3.1.0
info:
title: qwe,
version: 1.0.0
servers:
- url: https://api.example.com
paths:
/items/{itemId}:
get:
operationId: getItem
parameters:
-
name: itemId
in: path
required: true
schema:
type: string
responses:
'200':
description: ok
content:
application/json:
schema:
$ref: '#/components/schemas/Item'
'404':
description: not found
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
components:
schemas:
Item:
type: object
required: [id, name]
properties:
id:
type: string
name:
type: string
ApiError:
type: object
required: [code, message]
properties:
code:
type: string
message:
type: string
|
…it the const-object companion?
| 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`. | ||
| id: op.name, |
There was a problem hiding this comment.
Descriptor id drops original operationId
Medium Severity
After identifier sanitization, op.name is the coerced safe binding (for example list_orders), while the original OpenAPI operationId is kept on op.specName. The descriptor still sets id: op.name, so ctx.operation.id and the OperationId union no longer match the spec. Pagination already keys config off op.specName ?? op.name, so middleware targeting and config matching diverge for hyphenated or otherwise unsafe operationIds.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 8a94248. Configure here.
| * 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. | ||
| */ |
There was a problem hiding this comment.
Path params collide with slot keys
Medium Severity
Path parameter names are uniqued only against other path params, not against the reserved slots params, body, headers, cookies, and init. A path param with one of those names produces duplicate flat-style parameters and overwrites the grouped args object key, so generation can emit invalid TypeScript and drop the path value at runtime.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 8a94248. Configure here.
…uery framework-variant generators
| const used = new Set<string>([...WIRING_NAMES, ...authSetterNames(model.securitySchemes)]); | ||
| const idents = new Map<string, string>(); | ||
| for (const op of allOperations(model.services)) idents.set(op.name, uniqueIdent(op.name, used)); | ||
| return idents; |
There was a problem hiding this comment.
Schema names collide with reserved exports
Medium Severity
WIRING_NAMES / auth-setter reservation renames colliding operations (configure → configure_2), but schema names are only uniqued among themselves. A schema named Ops, Result, client, or OPERATIONS (or a string enum that emits export const <Name>) lands on the same type/value identifiers as the generated wiring and fails to compile. This matches the name-collision report in the PR discussion.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit ab6b6c9. Configure here.
vadyvas
left a comment
There was a problem hiding this comment.
ran it against the Rebilly spec via an AI POC, found a few issues
There was a problem hiding this comment.
createdTime:
$ref: CreatedTime.yaml # <-
updatedTime:
$ref: UpdatedTime.yamlclient:
dateType: Date
generators: [sdk, transformers]import { client, configure } from './out/client.ts';
import { transformCustomer } from './out/client.transformers.ts';
configure({
fetch: async () =>
new Response(
JSON.stringify({
id: 'cust_1',
createdTime: '2024-01-01T00:00:00Z', // $ref -> CreatedTime
lastPaymentTime: '2024-06-01T00:00:00Z', // inline date-time
}),
{ headers: { 'Content-Type': 'application/json' } }
),
});
const customer = transformCustomer(await client.GetCustomer({ id: 'cust_1' }));
console.log('createdTime:', customer.createdTime, '| Date?', customer.createdTime instanceof Date);
// createdTime: 2024-01-01T00:00:00Z | Date? false
customer.createdTime.getFullYear();
// TypeError: customer.createdTime.getFullYear is not a functionThere was a problem hiding this comment.
can't configure pagination
tried
client:
pagination:
style: offset
limitParam: limit
offsetParam: offset
items: /import { client, configure } from './out/client.ts';
import { transformCustomer } from './out/client.transformers.ts';
for await (const c of client.GetCustomerCollection.items({ params: { limit: 2 } })) {
console.log(c.id);
}Property 'items' does not exist on type '(args?: { params?: GetCustomerCollectionParams | undefined; } | undefined, init?: RequestOptions | undefined) => Promise<GetCustomerCollectionResult>'.ts(2339)
There was a problem hiding this comment.
Great job 👏🏼
I've pushed fixes
| } | ||
| if (typeof items !== 'string' || !items.startsWith('/')) { | ||
| return '"items" must be a JSON pointer starting with "/"'; | ||
| } |
There was a problem hiding this comment.
Root array pagination blocked
High Severity
ruleShapeProblem requires items to start with /, so the RFC 6901 root pointer "" is rejected even though both resolveSchemaPointer and runtime resolvePointer treat "" as the whole document. items: / passes validation but resolves to a property named "", so APIs whose success body is a bare array cannot enable .pages()/.items().
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 7d4db8a. Configure here.
| return schema.members.reduce<Record<string, unknown>>((acc, m) => { | ||
| const part = walk(m, byName, visiting, dateType); | ||
| return isPlainObject(part) ? Object.assign(acc, part) : acc; | ||
| }, {}); |
There was a problem hiding this comment.
Mocks break on allOf not
Medium Severity
OpenAPI not schemas fall through to unknown, and intersection sampling keeps only object-shaped members. For the common allOf: [$ref: Enum, not: { enum: [...] }] pattern the mock factory therefore emits {} for a string/enum field, so generated mocks fail type-checking against the sdk types.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 7d4db8a. Configure here.
…k sampling for nullable dates and narrowing intersections, root-array pagination items
…ake mock factories honor overrides for non-object schemas
| warnRename('schema', schema.name, safe); | ||
| schema.name = safe; | ||
| } | ||
| } |
There was a problem hiding this comment.
Schema names collide with wiring exports
High Severity
Operation names are renamed when they hit wiring exports (client, configure, use, OPERATIONS, auth setters, …) via packageIdents, but schema names are sanitized without that reserved set. A string-enum schema with one of those names still emits export const …, which then clashes with the generated client sugar and fails typecheck.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 8375e45. Configure here.
| (metadata?.format === 'date-time' || metadata?.format === 'date') | ||
| ) { | ||
| return factory.createTypeReferenceNode('Date'); | ||
| } |
There was a problem hiding this comment.
Date and Blob types get shadowed
Medium Severity
Emitted type nodes use bare Date, Blob, Record, and Omit identifiers. A same-named schema in the module shadows those builtins, so --date-type Date, binary fields, records, and Omit<…> request bodies resolve to the schema type instead of the platform type. Runtime Error was already hardened with globalThis.Error, but these positions were not.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 8375e45. Configure here.
…h runtime and generated module names
| } | ||
|
|
||
| if (kept.length === 0) return argText; | ||
| return `(() => {\n${kept.join('\n')}\nreturn ${argText};\n})()`; |
There was a problem hiding this comment.
Exported setup helpers break bake
Medium Severity
bakeSetup copies every non-import statement into an IIFE via getText, including export const / export function helpers. export is invalid inside a function body, so a normal setup module that exports helpers produces a syntax-invalid client.
Reviewed by Cursor Bugbot for commit 875f298. Configure here.
…add an AST-toolkit generator example
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 40 total unresolved issues (including 39 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 9913cc5. Configure here.
| if (p) ordered.push(p); | ||
| } | ||
| const used = new Set<string>(); | ||
| const pathParams = ordered.map((param) => ({ param, ident: uniqueIdent(param.name, used) })); |
There was a problem hiding this comment.
Path params collide with flat slots
Medium Severity
operationSignature builds path-param identifiers without reserving the flat-mode slot names (params, body, headers, cookies, init). A path parameter named init (always present as the trailing arg) or params/body/etc. when that slot exists emits a duplicate parameter list, so the generated client fails to compile.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 9913cc5. Configure here.
…erationId with spec operationIds
|
📦 A new experimental 🧪 version v0.0.0-snapshot.1784895845 of Redocly CLI has been published for testing. Install with NPM: npm install @redocly/cli@0.0.0-snapshot.1784895845 |
|
📦 A new experimental 🧪 version v0.0.0-snapshot.1784899983 of Redocly CLI has been published for testing. Install with NPM: npm install @redocly/cli@0.0.0-snapshot.1784899983 |






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
~40.5k added lines across 390 files — down from ~91k after a restructure pass: generated output is no longer committed (example and consumer clients are gitignored, regenerated and type-checked in CI), nine byte-identical example specs became one shared file, and redundant tests and golden snapshots were cut with no coverage of distinct behavior lost.
packages/client-generator/src, excl. tests)Reference
Testing
npm testgreen (compile + typecheck + unit + e2e); coverage thresholds pass — remaining unit-coverage gaps are paths the e2e suites exercise.tsc, and runs them against a mock server.tests/e2e/generate-client/examples/, regenerated and type-checked by the CIexamplesjob.Screenshots (optional)
Check yourself
Security
Note
High Risk
Large new experimental surface (codegen output, config schema, and runtime behavior) that consumer apps will depend on; mitigated by extensive unit/e2e coverage and CI example regeneration but API and output may still change in minors.
Overview
Introduces
@redocly/client-generatorand wiresredocly generate-clientinto the CLI so OpenAPI 3.x (and normalized Swagger 2.0) specs become typed TypeScript clients with optional add-ons (zod, TanStack Query, SWR, MSW mocks, transformers) via AST-based codegen and a shared fetch runtime (inline zero-dep orpackageimport).Configuration & docs:
redocly.yamlgains top-level and per-APIclient/clientOutput(merged in core config resolvers); command reference, usage guide, and ADRs document flags, pagination, and the experimental plugin API.CI & release: E2E runs in two shards; a new examples job regenerates example clients and type-checks consumers; snapshot publishing installs before version bumps and publishes
@redocly/client-generator; formatter/linter ignore generated e2e output paths.Reviewed by Cursor Bugbot for commit 4adcdc8. Bugbot is set up for automated code reviews on this repo. Configure here.