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: 0f71a56 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.
| const names: string[] = []; | ||
| if (schemes.some((s) => s.kind === 'bearer')) names.push('setBearer'); | ||
| if (schemes.some((s) => s.kind === 'basic')) names.push('setBasicAuth'); | ||
| const apiKeySchemes = schemes.filter((s) => s.kind.startsWith('apiKey')); |
There was a problem hiding this comment.
Should it be like s.kind === 'apiKey'?
Schemes kind is predefined and should match exact value.
There was a problem hiding this comment.
corrected to s.kind === 'apiKeyHeader' || s.kind === 'apiKeyQuery' || s.kind === 'apiKeyCookie'
… and zodValidation middleware
…allOf-derived zod schemas
Co-authored-by: Jacek Łękawa <164185257+JLekawa@users.noreply.github.com>
Co-authored-by: Jacek Łękawa <164185257+JLekawa@users.noreply.github.com>
Co-authored-by: Jacek Łękawa <164185257+JLekawa@users.noreply.github.com>
| errorMode: ErrorMode; | ||
| dateType: DateType; | ||
| /** Security-scheme keys whose credential is sent as a URL query param. */ | ||
| queryAuthKeys: Set<string>; |
There was a problem hiding this comment.
Unused queryAuthKeys leftover field
Low Severity
queryAuthKeys is computed into EmitContext and never read. It is leftover from the superseded call-site auth design in ADR-0007; auth query injection now happens entirely in the runtime resolveAuth capability, so this field is dead weight that invites confusion about an unused emit path.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 1b7ce53. Configure here.
| const title = doc.info?.title ?? 'Api'; | ||
| const version = doc.info?.version ?? '0.0.0'; | ||
| const description = doc.info?.description; | ||
| const serverUrl = doc.servers?.[0]?.url ?? ''; |
There was a problem hiding this comment.
Server URL variables left unsubstituted
Medium Severity
buildApiModel takes servers[0].url literally and never substitutes OpenAPI server variables with their defaults. Specs like https://{env}.example.com/{basePath} emit that template into the client, so requests hit an invalid base URL unless callers override with --server-url.
Reviewed by Cursor Bugbot for commit fae2394. Configure here.
…lients, and type-check examples in CI
…g consumer clients
…ng golden snapshots
… dispatch, and rename misleading modules
…e the generate-client docs
…command reference
…pec instead of cafe.yaml
…e modelWith helper
| return applyRule(op, model, perOp, `pagination.operations["${op.name}"]`, true); | ||
| } | ||
| if (op.paginationExtension !== undefined) { | ||
| return applyRule(op, model, op.paginationExtension, 'x-pagination', true); |
There was a problem hiding this comment.
Pagination misses sanitized operation IDs
Medium Severity
resolveOperationPagination looks up exclude and operations using the post-sanitization op.name, but authors configure those keys with the OpenAPI operationId. After sanitizeIdentifiers rewrites unsafe names (hyphens, reserved words like delete), those config entries no longer match, so exclusions and overrides are silently ignored.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 38f807b. Configure here.
| } | ||
| } | ||
| } | ||
| if (cookies.length > 0) headers.Cookie = cookies.join('; '); |
There was a problem hiding this comment.
Cookie API key fails in browsers
Medium Severity
Cookie apiKey credentials are applied by setting a Cookie request header. Browsers treat Cookie as a forbidden header and ignore it, so generated setters appear to work but send no credential. Cookie parameters are already skipped with a warning for this reason, while cookie apiKey schemes still emit working-looking auth with no caution.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 38f807b. Configure here.
…ntain permissions' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
… config by spec operationId, warn on cookie auth, drop dead queryAuthKeys
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 22 total unresolved issues (including 21 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 0f71a56. Configure here.
| if (!('Content-Type' in context.headers) && !('content-type' in context.headers)) { | ||
| context.headers['Content-Type'] = 'application/json'; | ||
| } | ||
| } |
There was a problem hiding this comment.
Multipart keeps preset Content-Type
Medium Severity
When the body is FormData or URLSearchParams (including typed multipart after serialization), send leaves any existing Content-Type header in place. Fetch only adds the multipart boundary when that header is absent, so a global or middleware Content-Type: application/json silently breaks uploads.
Reviewed by Cursor Bugbot for commit 0f71a56. Configure here.


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
Medium Risk
Large new surface area (codegen + runtime + dynamic custom generators at generation time) with broad e2e coverage, but experimental API/output may change and generated clients affect how consumers call external APIs.
Overview
Adds
@redocly/client-generator, a new workspace package that loads/bundles OpenAPI (including Swagger 2.0), builds a spec-agnostic IR, and emits clients via TypeScript AST codegen plus a hand-writtenfetchruntime (inlineembed vspackageimport). Theredocly generate-clientCLI command wires it up with flags for output mode, runtime, generators (zod, TanStack Query, SWR, transformers, mock), pagination config, publisher--setup, andredocly.yamlclient/ per-APIclientOutputsettings.Docs & release: Command reference, usage guide,
clientconfig reference, README section, changeset (minor bumps for cli/core/client-generator).CI & tooling: E2E split into two Vitest shards; new
examplesjob regenerates example clients and type-checks consumers; lint/format ignore patterns for generated e2e artifacts; root devDeps for MSW/faker/React Query/SWR/zod testing; CLI build banner adds__dirname/__filenamefor embedded runtime paths.Reviewed by Cursor Bugbot for commit 0f71a56. Bugbot is set up for automated code reviews on this repo. Configure here.