diff --git a/.changeset/ninety-phones-bow.md b/.changeset/ninety-phones-bow.md
new file mode 100644
index 0000000000..4dbe9d5504
--- /dev/null
+++ b/.changeset/ninety-phones-bow.md
@@ -0,0 +1,6 @@
+---
+'@redocly/openapi-core': patch
+'@redocly/cli': patch
+---
+
+Fixed an issue where the `bundle` command didn't resolve `$ref`s inside an AsyncAPI 3 Multi Format Schema Object.
diff --git a/.changeset/olive-donkeys-shave.md b/.changeset/olive-donkeys-shave.md
deleted file mode 100644
index 0abc20ff82..0000000000
--- a/.changeset/olive-donkeys-shave.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-'@redocly/openapi-core': patch
-'@redocly/cli': patch
----
-
-Fixed the `stats` command reporting wrong parameter count for AsyncAPI descriptions.
diff --git a/.changeset/seven-waves-create.md b/.changeset/seven-waves-create.md
deleted file mode 100644
index 707caa43be..0000000000
--- a/.changeset/seven-waves-create.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-'@redocly/openapi-core': minor
-'@redocly/cli': minor
----
-
-Added a Vendor Extensions metric to the `stats` command that reports how many distinct `x-` extensions a description file uses and how often each one occurs.
diff --git a/docs/@v2/changelog.md b/docs/@v2/changelog.md
index c1ccee9f03..3c10851013 100644
--- a/docs/@v2/changelog.md
+++ b/docs/@v2/changelog.md
@@ -7,6 +7,17 @@ toc:
+## 2.47.0 (2026-08-21)
+
+### Minor Changes
+
+- Added a Vendor Extensions metric to the `stats` command that reports how many distinct `x-` extensions a description file uses and how often each one occurs.
+
+### Patch Changes
+
+- Fixed the `stats` command reporting wrong parameter count for AsyncAPI descriptions.
+- Updated @redocly/openapi-core to v2.47.0.
+
## 2.46.2 (2026-08-19)
### Patch Changes
diff --git a/docs/@v2/commands/eject-generator.md b/docs/@v2/commands/eject-generator.md
index 4596419700..119d668551 100644
--- a/docs/@v2/commands/eject-generator.md
+++ b/docs/@v2/commands/eject-generator.md
@@ -2,7 +2,7 @@
## Introduction
-The `eject-generator` command copies a built-in client generator into your repository as an editable file.
+The `eject-generator` command copies a built-in client generator into your repository as editable source.
You own the ejected generator and can customize it.
The generated client stays generated and reproducible.
Do not edit it manually.
@@ -29,23 +29,20 @@ redocly eject-generator php --force
| ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| generator | string | The built-in generator to eject. |
| `--config` | string | The path to the config file. |
-| `--dir` | string | The directory that receives the ejected files. Default: `./generators`. |
+| `--dir` | string | The directory that receives the ejected copy. Default: `./generators`. |
| `--update` | boolean | Do a three-way merge of the current built-in version into your customized copy. The command marks conflicts with standard markers. |
-| `--force` | boolean | Overwrite an existing ejected file and discard the local edits. |
+| `--force` | boolean | Overwrite an existing ejected copy and discard the local edits. |
## How it works
-The eject operation writes two files:
+The eject operation writes the generator and its design:
-- `
/.mjs` is the generator itself, as a plain ESM file that you own.
- The file contains everything that it needs to run standalone.
- A language generator (`python`, `go`, `php`) is one self-contained file.
- You get its source exactly as it was written.
- A TypeScript generator is a thin entry point that uses shared emitters, so you get it bundled together with those emitters.
- The bundle is not minified, and a comment marks each source module.
+- Every generator ejects as `//` — its TypeScript source folder, exactly as it was written.
+ Each concern of the generator is one file, and `index.ts` is the entry.
+ Running an ejected generator uses Node's own type stripping, which requires Node 22.18, 23.6, or newer.
- In both cases, the file imports the authoring toolkit from `@redocly/client-generator`.
- A bundled generator also imports `logger` and `isPlainObject` from `@redocly/openapi-core`, which is a dependency of the toolkit.
+ The generator imports the authoring toolkit from `@redocly/client-generator`.
+ Some generators also import `logger` or `isPlainObject` from `@redocly/openapi-core`, which is a dependency of the toolkit; the command tells you when yours does.
If your package manager does not hoist dependencies, add `@redocly/openapi-core` explicitly.
- `.claude/skills/-generator/SKILL.md` is the design of the generator, written as an agent skill.
@@ -65,17 +62,17 @@ The command keeps everything that you add outside the markers in that file.
The eject command also configures your project.
It adds `@redocly/client-generator` to your `devDependencies` if the package is not there.
-It also points your config at the ejected file: in `client.generators`, the path to your copy replaces the built-in name.
+It also points your config at the ejected copy: in `client.generators`, the path to your copy replaces the built-in name.
If the config has no `client.generators` list yet, the command adds one.
```yaml
client:
generators:
- - ./generators/python.mjs
+ - ./generators/python/index.ts
```
If you leave the ejected generator unmodified, its output is byte-identical to the output of the built-in generator.
-To roll back, delete the file and the config line.
+To roll back, delete the ejected copy and the config line.
## Run the ejected generator
@@ -85,21 +82,21 @@ Generation is the same command as before the eject, because the config now point
redocly generate-client openapi.yaml --output src/client.ts
```
-If you did not wire the config, name the file with `--generator`:
+If you did not wire the config, name your copy with `--generator`:
```sh
-redocly generate-client openapi.yaml --output src/client.ts --generator ./generators/python.mjs
+redocly generate-client openapi.yaml --output src/client.ts --generator ./generators/python/index.ts
```
The command reports a generator that takes over a built-in name, so you can see that your copy is the one that runs.
-Edit the file and run the command again to see the change.
+Edit your copy and run the command again to see the change.
The eject command prints these instructions as well.
## Update an ejected generator
The `redocly eject-generator --update` command merges a newer version into your copy.
That version is the one shipped by your installed `@redocly/client-generator` package.
-The three-way merge uses the version recorded in the header of the ejected file as the common ancestor.
+The three-way merge uses the version recorded in the header of each ejected file as the common ancestor, and a folder generator merges file by file.
Because of this, you do not have to commit extra files, and there is no snapshot to keep in sync.
The command merges the two skills in the same way, so an update keeps the design notes that you added to them.
diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md
index f2f5d41540..bed6263d9d 100644
--- a/docs/@v2/commands/generate-client.md
+++ b/docs/@v2/commands/generate-client.md
@@ -76,7 +76,7 @@ redocly generate-client [--help] [--version]
| `api` | string | The file path to the OpenAPI description, a URL, or an `apis:` alias. Omit it to generate a client for each api that has a `client` block or `clientOutput`. |
| `--output`, `-o` | string | The output path (it must end in `.ts`). In multi-file modes, this is the entry file. Defaults to the `clientOutput` of the api, else `.client.ts` next to the configuration file. Use this option only when you generate one API. |
| `--output-mode` | string | The file layout. See [Choose an output mode](#choose-an-output-mode).
**Possible values:** `single`, `split`. Default: `single`. |
-| `--runtime` | string | The location of the client engine. See [Choose a runtime](#choose-a-runtime).
**Possible values:** `inline`, `package`. Default: `inline`. |
+| `--runtime` | string | The location of the client engine.
**Possible values:** `inline`, `module`. Default: `inline`. |
| `--import-ext` | string | The extension in the generated relative imports. See [Run with Node directly](../guides/use-generated-client.md#run-with-node-directly).
**Possible values:** `js` (the tsc/bundler convention), `ts` (for Node's built-in type stripping). Default: `js`. |
| `--generator` | [string] | The generator to run: a built-in name, or the path or package of a custom generator. Repeat the flag to run more than one generator. Default value is `typescript`. See [Generators](../guides/use-generated-client.md#generators) for the full list. |
| `--args-style` | string | Sets how you pass inputs to operations. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `grouped`, `flat`. Default: `grouped`. |
@@ -141,22 +141,25 @@ The `--output-mode` flag controls how the command splits the client into files:
redocly generate-client openapi.yaml -o src/api/client.ts --output-mode split
```
-Both modes work with both runtimes.
-
### Choose a runtime
-The `--runtime` flag controls the location of the client engine (request building, auth, retries, middleware, SSE):
+The `--runtime` flag controls where the client engine lives:
+
+- `inline` (default): the engine is embedded in the generated file, so the client is one self-contained file.
+- `module`: the command writes the engine as real files in a `runtime/` folder beside the client, and the client imports them relatively.
+ Several generated clients in one repository can share one `runtime/` folder, and you can read the engine as ordinary source files.
+ The files are still machine-owned: the command regenerates them on every run.
+
+Every generator that embeds an engine supports both modes, each in its language's shape:
-- `inline` (default): the command embeds the runtime source in the generated output.
- It embeds only the parts that your API needs.
- The output is self-contained and has zero runtime dependencies.
-- `package`: the generated file imports the runtime from `@redocly/client-generator`.
- The file contains only the types, the operation descriptors, and thin call wrappers.
+- `typescript` and `cli` write `runtime/*.ts` modules.
+- `python` writes the `_*.py` runtime modules beside the client, which imports them.
+- `go` writes a `runtime.go` file in the same package as the client.
+- `php` writes a `runtime.php` file that the client loads with `require_once`.
-Choose `package` if you want to get engine fixes with `npm update @redocly/client-generator` and no regeneration.
-In this mode, the app that uses the client must install that package as a regular dependency.
-Your application code is the same in both modes.
-See [Package runtime](../guides/use-generated-client.md#package-runtime) in the usage guide and the [`package-runtime` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/package-runtime).
+```bash
+redocly generate-client openapi.yaml -o src/api/client.ts --runtime module
+```
## Resources
diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md
index ecfa9b3db5..ae33f763e2 100644
--- a/docs/@v2/configuration/reference/client.md
+++ b/docs/@v2/configuration/reference/client.md
@@ -26,7 +26,7 @@ As an alternative, pass `pagination` to the programmatic `generateClient(...)`.
| ----------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `generators` | [string] | The generators to run, in order. Each entry is a built-in name (`typescript`, `zod`, `tanstack-query` or its `-vue`/`-svelte`/`-solid` variants, `swr`, `mock`, `transformers`, `cli`, `python`, `go`, `php`), or the path or package name of a custom generator. |
| `outputMode` | string | The file layout: `single` or `split`. This option applies to TypeScript output only. The `python`, `go`, and `php` SDKs always emit one self-contained file. |
-| `runtime` | string | The runtime distribution: `inline` or `package`. This option applies to TypeScript output only. The `python`, `go`, and `php` SDKs always embed their runtime. |
+| `runtime` | string | The runtime distribution: `inline` (the runtime is embedded in the generated output) or `module` (the runtime is written as real files in a `runtime/` folder beside the client). |
| `importExt` | string | The extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). This option applies to TypeScript output only. |
| `argsStyle` | string | How the client receives operation inputs: `grouped` (default) groups them by transport layer (`path`, `query`, `headers`, `cookies`, `body`), and `flat` merges them into one object. This option applies to TypeScript output only. Each language SDK follows its own idiom (keyword arguments, named arguments, a params struct). |
| `errorMode` | string | How operations report HTTP errors: `throw` or `result`. The `python` SDK implements both. The `go` and `php` SDKs support only `throw`, because that is the language idiom, and they reject `result`. |
diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md
index 694cb70932..ba6e0e22fa 100644
--- a/docs/@v2/guides/customize-client-generation.md
+++ b/docs/@v2/guides/customize-client-generation.md
@@ -77,7 +77,7 @@ See the [`baked-setup` example](https://github.com/Redocly/redocly-cli/tree/main
The quickest method to get a customized generator is
[`redocly eject-generator `](../commands/eject-generator.md).
-The command copies any built-in generator into `./generators/` as an editable file that you own.
+The command copies any built-in generator into `./generators/` as its TypeScript source folder — editable source that you own.
An ejected generator with no changes produces byte-identical output.
In `client.generators`, the path to your copy replaces the built-in name.
Because of this, `redocly generate-client` now runs your version.
@@ -142,7 +142,7 @@ export default defineGenerator({
properties: { groupBy: { enum: ['tag', 'path'], default: 'tag' } },
additionalProperties: false,
},
- run({ model, outputPath, options }) {
+ run({ model, output, options }) {
// `options` is validated against the schema before `run` is called.
},
});
@@ -204,7 +204,7 @@ Your coding agent then has the contract, the model reference, and this helper ta
TypeScript is one more output language.
The `@redocly/client-generator/generate` entry exports the TypeScript-specific renderers.
-These renderers are not on the package root, so the import graph of a `runtime: 'package'` client never includes the generation toolkit.
+These renderers are not on the package root, which stays a small authoring surface.
`tsType` is the schema-to-type renderer that the built-in `typescript` generator itself uses.
Because of this, the mapping (refs, arrays, unions, formats, parenthesization) is exactly the same as in the generated client:
@@ -214,7 +214,7 @@ import { tsType } from '@redocly/client-generator/generate';
export default {
name: 'response-map',
requires: ['typescript'],
- run({ model, outputPath }) {
+ run({ model, output }) {
const members = model.services
.flatMap((service) => service.operations)
.flatMap((op) => {
@@ -223,7 +223,7 @@ export default {
});
return [
{
- path: outputPath.replace(/\.ts$/, '.responses.ts'),
+ path: output.path.replace(/\.ts$/, '.responses.ts'),
content: `export type ResponseShapes = {\n${members.join('\n')}\n};\n`,
},
];
@@ -295,14 +295,14 @@ const rubyCall = (operation) => ({ lang: 'ruby', source: `client.${operation.nam
export default defineGenerator({
name: 'ruby',
- run({ model, outputPath }) {
+ run({ model, output }) {
/* the SDK */
},
sample: rubyCall,
- docs({ model, outputPath, emit }) {
+ docs({ model, output, emit }) {
return [
{
- path: outputPath.replace(/\.[^.\\/]+$/, '.ruby.md'),
+ path: output.path.replace(/\.[^.\\/]+$/, '.ruby.md'),
content: renderReferencePage(model, {
title: `${model.title} Ruby SDK reference`,
frontmatter: emit.docsFrontmatter === true,
diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md
index c878e578bf..c9d5ef99eb 100644
--- a/docs/@v2/guides/use-generated-client.md
+++ b/docs/@v2/guides/use-generated-client.md
@@ -161,8 +161,8 @@ Use this to add behavior that is not in a description, for example a `login` or
The custom command lives in a file that you own:
```ts
-import { runCli, type CustomCommand } from '@redocly/client-generator';
-import { SOURCES } from './src/cafe.ts'; // the composed entry exports its sources
+import type { CustomCommand } from '@redocly/client-generator';
+import { runCli, SOURCES } from './src/cafe.ts'; // the composed entry exports its sources and the engine
const login: CustomCommand = {
name: 'login',
@@ -405,30 +405,6 @@ Set `client.docsFrontmatter: true` to put YAML front matter with the title above
For a different structure or wording, [eject the generator](../commands/eject-generator.md) that owns the page.
The renderer is the template, so an ejected generator keeps writing its page and you own the layout.
-## Package runtime
-
-By default, the generator embeds the runtime in the generated file, so the client is self-contained.
-With [`--runtime package`](../commands/generate-client.md#choose-a-runtime), the generated file imports the runtime from `@redocly/client-generator` instead.
-Your application code is **identical in both modes**: the same exports and the same call shapes.
-Only the location of the engine changes.
-Select `package` to get engine fixes and improvements through `npm update @redocly/client-generator`, with no regeneration.
-
-Install the runtime as a regular dependency and set the mode in `redocly.yaml`:
-
-```sh
-npm install @redocly/client-generator
-```
-
-```yaml
-client:
- runtime: package # default: inline (self-contained)
-```
-
-If the generated file and the runtime are incompatible, your `tsc` build fails on the descriptor `satisfies` check.
-The pair does not misbehave at runtime.
-Package mode works with both output modes and every generator.
-See the [`package-runtime` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/package-runtime).
-
## Run with Node directly
Node 22.7+ runs TypeScript natively with type stripping.
@@ -507,11 +483,10 @@ client.auth.bearer(async () => await getFreshAccessToken());
The client resolves the provider for each request, so a refreshed token takes effect without reconfiguration.
For **multiple independent instances** with different credentials, build extra clients from the same generated descriptors.
-The generated module exports `createClient`, the `OPERATIONS` descriptors, and the `Ops` type in both runtimes:
+The generated module exports `createClient`, the `OPERATIONS` descriptors, and the `Ops` type in both runtime modes:
```ts
-import { createClient } from '@redocly/client-generator';
-import { OPERATIONS, type Ops } from './client.ts';
+import { createClient, OPERATIONS, type Ops } from './client.ts';
const internal = createClient(OPERATIONS, {
serverUrl: 'https://api.example.com',
diff --git a/package-lock.json b/package-lock.json
index 81be612fbc..53cc92abcf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11050,7 +11050,7 @@
},
"packages/cli": {
"name": "@redocly/cli",
- "version": "2.46.2",
+ "version": "2.47.0",
"license": "MIT",
"bin": {
"openapi": "bin/cli.js",
@@ -11062,9 +11062,9 @@
"@opentelemetry/sdk-trace-node": "2.8.0",
"@opentelemetry/semantic-conventions": "1.41.1",
"@redocly/cli-otel": "0.3.5",
- "@redocly/client-generator": "0.3.7",
- "@redocly/openapi-core": "2.46.2",
- "@redocly/respect-core": "2.46.2",
+ "@redocly/client-generator": "0.3.8",
+ "@redocly/openapi-core": "2.47.0",
+ "@redocly/respect-core": "2.47.0",
"@types/cookie": "0.6.0",
"@types/har-format": "^1.2.16",
"@types/react": "^17.0.0 || ^18.2.21 || ^19.2.16",
@@ -11097,10 +11097,10 @@
},
"packages/client-generator": {
"name": "@redocly/client-generator",
- "version": "0.3.7",
+ "version": "0.3.8",
"license": "MIT",
"dependencies": {
- "@redocly/openapi-core": "2.46.2"
+ "@redocly/openapi-core": "2.47.0"
},
"devDependencies": {
"typescript": "6.0.2"
@@ -11120,7 +11120,7 @@
},
"packages/core": {
"name": "@redocly/openapi-core",
- "version": "2.46.2",
+ "version": "2.47.0",
"license": "MIT",
"dependencies": {
"@redocly/ajv": "^8.18.3",
@@ -11200,13 +11200,13 @@
},
"packages/respect-core": {
"name": "@redocly/respect-core",
- "version": "2.46.2",
+ "version": "2.47.0",
"license": "MIT",
"dependencies": {
"@faker-js/faker": "^7.6.0",
"@noble/hashes": "^1.8.0",
"@redocly/ajv": "^8.18.3",
- "@redocly/openapi-core": "2.46.2",
+ "@redocly/openapi-core": "2.47.0",
"ajv": "npm:@redocly/ajv@^8.18.3",
"better-ajv-errors": "^2.0.3",
"colorette": "^2.0.20",
diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md
index 4c2b10ce71..e28806c1b5 100644
--- a/packages/cli/CHANGELOG.md
+++ b/packages/cli/CHANGELOG.md
@@ -1,5 +1,16 @@
# @redocly/cli
+## 2.47.0
+
+### Minor Changes
+
+- Added a Vendor Extensions metric to the `stats` command that reports how many distinct `x-` extensions a description file uses and how often each one occurs.
+
+### Patch Changes
+
+- Fixed the `stats` command reporting wrong parameter count for AsyncAPI descriptions.
+- Updated @redocly/openapi-core to v2.47.0.
+
## 2.46.2
### Patch Changes
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 6d5538b90e..34feb1d6a3 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@redocly/cli",
- "version": "2.46.2",
+ "version": "2.47.0",
"description": "",
"license": "MIT",
"bin": {
@@ -45,9 +45,9 @@
"@opentelemetry/sdk-trace-node": "2.8.0",
"@opentelemetry/semantic-conventions": "1.41.1",
"@redocly/cli-otel": "0.3.5",
- "@redocly/client-generator": "0.3.7",
- "@redocly/openapi-core": "2.46.2",
- "@redocly/respect-core": "2.46.2",
+ "@redocly/client-generator": "0.3.8",
+ "@redocly/openapi-core": "2.47.0",
+ "@redocly/respect-core": "2.47.0",
"@types/cookie": "0.6.0",
"@types/har-format": "^1.2.16",
"@types/react": "^17.0.0 || ^18.2.21 || ^19.2.16",
diff --git a/packages/cli/src/commands/__tests__/eject-generator.test.ts b/packages/cli/src/commands/__tests__/eject-generator.test.ts
index e2dcdeb1ab..5fb3840b88 100644
--- a/packages/cli/src/commands/__tests__/eject-generator.test.ts
+++ b/packages/cli/src/commands/__tests__/eject-generator.test.ts
@@ -30,7 +30,7 @@ describe('wireConfig', () => {
const configPath = join(dir, 'redocly.yaml');
writeFileSync(configPath, source, 'utf-8');
try {
- expect(wireConfig(configPath, 'php', './generators/php.mjs')).toBe(true);
+ expect(wireConfig(configPath, 'php', './generators/php/index.ts')).toBe(true);
return readFileSync(configPath, 'utf-8');
} finally {
rmSync(dir, { recursive: true, force: true });
@@ -48,11 +48,11 @@ describe('wireConfig', () => {
).toBe(outdent`
client:
generators:
- - ./generators/php.mjs
+ - ./generators/php/index.ts
- typescript
`);
expect(wire('client:\n generators: [php, typescript]\n')).toBe(
- 'client:\n generators: [./generators/php.mjs, typescript]\n'
+ 'client:\n generators: [./generators/php/index.ts, typescript]\n'
);
});
@@ -67,7 +67,7 @@ describe('wireConfig', () => {
client:
generators:
- typescript
- - ./generators/php.mjs
+ - ./generators/php/index.ts
`);
});
@@ -75,7 +75,7 @@ describe('wireConfig', () => {
expect(
wire(outdent`
client:
- runtime: package
+ errorMode: result
apis:
cafe:
root: ./openapi.yaml
@@ -83,8 +83,8 @@ describe('wireConfig', () => {
).toBe(outdent`
client:
generators:
- - ./generators/php.mjs
- runtime: package
+ - ./generators/php/index.ts
+ errorMode: result
apis:
cafe:
root: ./openapi.yaml
@@ -95,23 +95,23 @@ describe('wireConfig', () => {
// A mention outside the list (a comment, a longer path) is not wiring.
expect(
wire(outdent`
- # was: ./generators/php.mjs
+ # was: ./generators/php/index.ts
client:
generators:
- typescript
`)
).toBe(outdent`
- # was: ./generators/php.mjs
+ # was: ./generators/php/index.ts
client:
generators:
- typescript
- - ./generators/php.mjs
+ - ./generators/php/index.ts
`);
// A real list entry is — the file stays unchanged.
const wired = outdent`
client:
generators:
- - ./generators/php.mjs
+ - ./generators/php/index.ts
`;
expect(wire(wired)).toBe(wired);
});
@@ -129,7 +129,7 @@ describe('wireConfig', () => {
client:
generators:
# our copies:
- - ./generators/php.mjs # ours
+ - ./generators/php/index.ts # ours
- typescript
`);
// An already-wired entry behind a comment line is found, not duplicated.
@@ -138,7 +138,7 @@ describe('wireConfig', () => {
generators:
- typescript
# ejected:
- - ./generators/php.mjs
+ - ./generators/php/index.ts
`;
expect(wire(wired)).toBe(wired);
});
@@ -160,7 +160,7 @@ describe('wireConfig', () => {
'utf-8'
);
try {
- expect(wireConfig(configPath, 'php', './generators/php.mjs')).toBe(false);
+ expect(wireConfig(configPath, 'php', './generators/php/index.ts')).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
@@ -184,7 +184,7 @@ describe('wireConfig', () => {
clientOutput: ./src/client.ts
client:
generators:
- - ./generators/php.mjs
+ - ./generators/php/index.ts
` + '\n'
);
});
@@ -276,13 +276,13 @@ describe('packedAssets', () => {
// A directory stands in for the version spec `--update` passes: same pack, same
// extraction, no registry needed to prove the mechanism.
const members = [
- 'package/eject-assets/generators/php.mjs',
+ 'package/eject-assets/generators/php/index.ts',
'package/eject-assets/skills/php-generator/SKILL.md',
'package/eject-assets/skills/not-a-member/SKILL.md',
];
const assets = packedAssets(clientGeneratorDir, members);
expect(assets.get(members[0])).toBe(
- readFileSync(join(clientGeneratorDir, 'eject-assets/generators/php.mjs'), 'utf-8')
+ readFileSync(join(clientGeneratorDir, 'eject-assets/generators/php/index.ts'), 'utf-8')
);
expect(assets.get(members[1])).toBe(
readFileSync(join(clientGeneratorDir, 'eject-assets/skills/php-generator/SKILL.md'), 'utf-8')
diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts
index f2fa3d9b8d..844b9325bb 100644
--- a/packages/cli/src/commands/eject-generator.ts
+++ b/packages/cli/src/commands/eject-generator.ts
@@ -8,6 +8,7 @@ import {
readFileSync,
realpathSync,
rmSync,
+ statSync,
writeFileSync,
} from 'node:fs';
import { createRequire } from 'node:module';
@@ -221,8 +222,23 @@ export function packedAssets(spec: string, members: string[]): Map `package/eject-assets/generators/${name}.mjs`;
+const folderMember = (name: string, file: string) =>
+ `package/eject-assets/generators/${name}/${file}`;
const skillMember = (skill: string) => `package/eject-assets/skills/${skill}/SKILL.md`;
+/**
+ * How a generator ships: the language generators are FOLDERS of TypeScript stage files
+ * (ejected verbatim, run under Node's type stripping); the TypeScript-family generators
+ * are one bundled `.mjs` each.
+ */
+function assetFolderFiles(assetsDir: string, name: string): string[] | undefined {
+ const folder = join(assetsDir, 'generators', name);
+ if (!existsSync(folder) || !statSync(folder).isDirectory()) return undefined;
+ return readdirSync(folder)
+ .filter((file) => file.endsWith('.ts'))
+ .sort();
+}
+
/**
* Refresh one skill during `--update`. The skill tells its owner to edit it first, so it
* gets the same three-way merge as the generator: ours is the user's copy, the base is
@@ -254,7 +270,9 @@ function updateSkill(skill: string, assetsDir: string, baseSkill: string | undef
/** The built-in generators already ejected into `dir`, so the pointer lists every one of them. */
function ejectedIn(dir: string): string[] {
- return [...EJECTABLE].filter((name) => existsSync(join(dir, `${name}.mjs`)));
+ return [...EJECTABLE].filter(
+ (name) => existsSync(join(dir, `${name}.mjs`)) || existsSync(join(dir, name, 'index.ts'))
+ );
}
/**
@@ -406,6 +424,111 @@ export function wireConfig(configPath: string | undefined, name: string, entry:
return true;
}
+/**
+ * The `--update` flow for a folder generator: three-way-merge each stage file the new
+ * version ships (a file the user lacks is written new; a base that cannot be fetched
+ * leaves the user's copy and drops the update beside it as `.new`), merge the two
+ * skills the same way, and report the total conflict count. Files the user added and the
+ * new version does not ship are left alone — they are the user's.
+ */
+function updateEjectedFolder({
+ name,
+ files,
+ toolkitVersion,
+ assetsDir,
+ dir,
+ targetDir,
+}: {
+ name: string;
+ files: string[];
+ toolkitVersion: string;
+ assetsDir: string;
+ dir: string;
+ targetDir: string;
+}): void {
+ const entry = join(targetDir, 'index.ts');
+ const printedEntry = relative(process.cwd(), entry) || entry;
+ if (!existsSync(entry)) {
+ ejectGeneratorTelemetry.eject_generator_outcome = 'missing-target';
+ const legacy = join(dir, `${name}.mjs`);
+ throw new HandledError(
+ existsSync(legacy)
+ ? `\n❌ ${relative(process.cwd(), legacy)} is a single-file eject from an older version; this version ejects a folder. Eject fresh: redocly eject-generator ${name} --force\n`
+ : `\n❌ Nothing to update: ${printedEntry} does not exist. Eject first.\n`
+ );
+ }
+ const from = recordedVersion(readFileSync(entry, 'utf-8'));
+ if (from !== undefined && semver.valid(from) !== null) {
+ ejectGeneratorTelemetry.eject_generator_from_version = from;
+ }
+ ejectGeneratorTelemetry.eject_generator_to_version = toolkitVersion;
+ // One pack fetches every merge base: each stage file plus both skills.
+ const packed =
+ from === toolkitVersion || from === undefined
+ ? new Map()
+ : packedAssets(`${TOOLKIT_PACKAGE}@${from}`, [
+ ...files.map((file) => folderMember(name, file)),
+ skillMember('client-generators'),
+ skillMember(`${name}-generator`),
+ ]);
+ if (from === undefined) {
+ ejectGeneratorTelemetry.eject_generator_outcome = 'missing-base';
+ throw new HandledError(
+ `\n❌ Could not read the version ${printedEntry} was ejected from (not recorded in its header), so there is no merge base.\n` +
+ ` Eject to a temporary directory and diff by hand, or re-eject with --force.\n`
+ );
+ }
+ let conflicts = 0;
+ for (const file of files) {
+ const updated = readFileSync(join(assetsDir, 'generators', name, file), 'utf-8');
+ const target = join(targetDir, file);
+ if (!existsSync(target)) {
+ writeFileSync(target, updated, 'utf-8');
+ continue;
+ }
+ const customized = readFileSync(target, 'utf-8');
+ if (customized === updated) continue;
+ const base = from === toolkitVersion ? updated : packed.get(folderMember(name, file));
+ if (base === undefined) {
+ writeFileSync(`${target}.new`, updated, 'utf-8');
+ logger.warn(
+ `${relative(process.cwd(), target)} has no merge base in ${TOOLKIT_PACKAGE}@${from} — the new file is beside it as ${file}.new.\n`
+ );
+ continue;
+ }
+ const merged = threeWayMerge(customized, base, updated);
+ writeFileSync(target, merged.merged, 'utf-8');
+ conflicts += merged.conflicts;
+ }
+ const skillBase = (skill: string): string | undefined =>
+ from === toolkitVersion
+ ? readFileSync(join(assetsDir, 'skills', skill, 'SKILL.md'), 'utf-8')
+ : packed.get(skillMember(skill));
+ const skillConflicts =
+ updateSkill('client-generators', assetsDir, skillBase('client-generators')) +
+ updateSkill(`${name}-generator`, assetsDir, skillBase(`${name}-generator`));
+ dropPointer(dir, ejectedIn(dir));
+ const dependency = wireDependency({ [TOOLKIT_PACKAGE]: toolkitVersion }, true);
+ if (dependency === 'updated' || dependency === 'added') {
+ logger.info(
+ `Set ${TOOLKIT_PACKAGE} to ^${toolkitVersion} in package.json — run your installer.\n`
+ );
+ }
+ const totalConflicts = conflicts + skillConflicts;
+ ejectGeneratorTelemetry.eject_generator_outcome = totalConflicts > 0 ? 'conflicts' : 'success';
+ const printedDir = relative(process.cwd(), targetDir) || targetDir;
+ if (totalConflicts > 0) {
+ ejectGeneratorTelemetry.eject_generator_conflicts = totalConflicts;
+ logger.warn(
+ `Updated ${printedDir} with ${totalConflicts} conflict(s)${
+ skillConflicts > 0 ? ' (some in .claude/skills)' : ''
+ } — resolve the <<<<<<< markers, then regenerate.\n`
+ );
+ } else {
+ logger.info(`Updated ${printedDir} cleanly.\n`);
+ }
+}
+
/**
* The `--update` flow: three-way-merge the newer built-in version into the user's copy,
* merging the two skills the same way, and report the conflict count.
@@ -523,7 +646,7 @@ export const handleEjectGenerator = async ({
`\nThe "${name}" generator is the "tanstack-query" generator with one argument changed.\n` +
`Eject that one and set the framework in your copy's default export:\n\n` +
` redocly eject-generator tanstack-query\n` +
- ` # then in generators/tanstack-query.mjs: run: tanstackQueryGenerator('${framework}')\n`
+ ` # then in generators/tanstack-query/index.ts: run: tanstackQueryGenerator('${framework}')\n`
);
return;
}
@@ -535,16 +658,37 @@ export const handleEjectGenerator = async ({
}
const assetsDir = ejectAssetsDir();
- const asset = readFileSync(join(assetsDir, 'generators', `${name}.mjs`), 'utf-8');
+ const folderFiles = assetFolderFiles(assetsDir, name);
// The ejected file records and imports the toolkit's version; the CLI versions
// independently of it.
const { GENERATOR_VERSION: toolkitVersion } = await import('@redocly/client-generator');
const dir = resolve(argv.dir ?? './generators');
- const target = join(dir, `${name}.mjs`);
+ // A folder generator's existence, config entry, and provenance all key on its index.ts.
+ const target = folderFiles === undefined ? join(dir, `${name}.mjs`) : join(dir, name, 'index.ts');
const printedTarget = relative(process.cwd(), target) || target;
if (argv.update) {
- updateEjectedGenerator({ name, asset, toolkitVersion, assetsDir, dir, target, printedTarget });
+ if (folderFiles === undefined) {
+ const asset = readFileSync(join(assetsDir, 'generators', `${name}.mjs`), 'utf-8');
+ updateEjectedGenerator({
+ name,
+ asset,
+ toolkitVersion,
+ assetsDir,
+ dir,
+ target,
+ printedTarget,
+ });
+ } else {
+ updateEjectedFolder({
+ name,
+ files: folderFiles,
+ toolkitVersion,
+ assetsDir,
+ dir,
+ targetDir: join(dir, name),
+ });
+ }
return;
}
@@ -554,8 +698,18 @@ export const handleEjectGenerator = async ({
`\n❌ ${printedTarget} already exists. Use --update to merge the newer version in, or --force to overwrite.\n`
);
}
- mkdirSync(dir, { recursive: true });
- writeFileSync(target, asset, 'utf-8');
+ mkdirSync(dirname(target), { recursive: true });
+ const copied =
+ folderFiles === undefined
+ ? [readFileSync(join(assetsDir, 'generators', `${name}.mjs`), 'utf-8')]
+ : folderFiles.map((file) => readFileSync(join(assetsDir, 'generators', name, file), 'utf-8'));
+ if (folderFiles === undefined) {
+ writeFileSync(target, copied[0], 'utf-8');
+ } else {
+ folderFiles.forEach((file, index) => {
+ writeFileSync(join(dir, name, file), copied[index], 'utf-8');
+ });
+ }
const authoringSkill = dropSkill('client-generators', assetsDir);
const designSkill = dropSkill(`${name}-generator`, assetsDir);
dropPointer(dir, ejectedIn(dir));
@@ -569,10 +723,15 @@ export const handleEjectGenerator = async ({
.join('/')}`;
const dependency = wireDependency({ [TOOLKIT_PACKAGE]: toolkitVersion });
// A bundled TypeScript generator also imports from core; without hoisting it must be explicit.
- const needsCore = asset.includes(`from "${CORE_PACKAGE}"`);
+ const needsCore = copied.some(
+ (source) =>
+ source.includes(`from "${CORE_PACKAGE}"`) || source.includes(`from '${CORE_PACKAGE}'`)
+ );
const wired = wireConfig(config.configPath, name, configEntry);
+ const printedLocation =
+ folderFiles === undefined ? printedTarget : relative(process.cwd(), join(dir, name)) + '/';
logger.info(
- `Ejected the "${name}" generator to ${printedTarget}.\n` +
+ `Ejected the "${name}" generator to ${printedLocation}.\n` +
(dependency === 'added'
? `Added ${TOOLKIT_PACKAGE} to devDependencies (the ejected file imports its toolkit) — run your installer.\n`
: dependency === 'no-package-json'
@@ -583,14 +742,17 @@ export const handleEjectGenerator = async ({
: '') +
(wired
? `Added it to client.generators in ${relative(process.cwd(), config.configPath!)} — the path to your copy replaces the built-in name.\n`
- : `Point your config at the file — the path to your copy replaces the built-in name:\n\n` +
+ : `Point your config at the ${folderFiles === undefined ? 'file' : 'entry file'} — the path to your copy replaces the built-in name:\n\n` +
` client:\n generators:\n - ${configEntry}\n\n`) +
+ (folderFiles === undefined
+ ? ''
+ : `Running TypeScript generators uses Node's type stripping — Node 22.18 or 23.6 and newer.\n`) +
`Your agent's skills: ${designSkill} (this generator's design) and ${authoringSkill} (the toolkit).\n` +
// The next command, spelled out: a wired config still needs an output, and an unwired
// copy is reached with `--generator`. Either way the reader can run it without
// leaving the terminal to look it up.
`\nRun it: redocly generate-client --output ${wired ? '' : ` --generator ${configEntry}`}\n` +
- `Edit ${printedTarget} and run that again to see your change.\n` +
+ `Edit ${printedLocation} and run that again to see your change.\n` +
`Reference: ${DOCS_URL}\n`
);
// Last, so wiring the dependency or the config entry failing is not reported as success.
diff --git a/packages/cli/src/commands/generate-client.ts b/packages/cli/src/commands/generate-client.ts
index 13ea826a11..a302647b88 100644
--- a/packages/cli/src/commands/generate-client.ts
+++ b/packages/cli/src/commands/generate-client.ts
@@ -32,7 +32,7 @@ export type GenerateClientCommandArgv = {
config?: string;
'server-url'?: string;
'output-mode'?: 'single' | 'split';
- runtime?: 'inline' | 'package';
+ runtime?: 'inline' | 'module';
'import-ext'?: 'js' | 'ts';
'go-package'?: string;
'args-style'?: 'flat' | 'grouped';
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index f2685b9349..0993271f1b 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -896,8 +896,8 @@ yargs(hideBin(process.argv))
},
runtime: {
describe:
- "Runtime distribution: 'inline' (default) embeds the runtime in the generated file; 'package' imports it from @redocly/client-generator.",
- choices: ['inline', 'package'] as const,
+ "Runtime distribution: 'inline' (default) embeds the runtime in the generated file; 'module' writes it as real files in a runtime/ folder beside the client.",
+ choices: ['inline', 'module'] as const,
requiresArg: true,
},
docs: {
diff --git a/packages/client-generator/CHANGELOG.md b/packages/client-generator/CHANGELOG.md
index 9bb3dcb765..19c78d8bf0 100644
--- a/packages/client-generator/CHANGELOG.md
+++ b/packages/client-generator/CHANGELOG.md
@@ -1,5 +1,11 @@
# @redocly/client-generator
+## 0.3.8
+
+### Patch Changes
+
+- Updated @redocly/openapi-core to v2.47.0.
+
## 0.3.7
### Patch Changes
diff --git a/packages/client-generator/README.md b/packages/client-generator/README.md
index 7f878dd962..ceb6f6f134 100644
--- a/packages/client-generator/README.md
+++ b/packages/client-generator/README.md
@@ -11,7 +11,7 @@ See https://github.com/Redocly/redocly-cli for the full project.
The generated client uses only web-standard APIs (`fetch`, `AbortController`, `URLSearchParams`), so by default it is a single self-contained file with zero runtime dependencies that runs in browsers, Node ≥ 18, Bun, Deno, and edge runtimes.
(Running the generator itself requires the Node version in this package's `engines` field.)
Code is produced through the TypeScript compiler AST, not string templates; `typescript` is the only peer dependency — optional, needed only when you run generation, and it must be 6.x there (TypeScript 7's native compiler has no compiler API).
-Apps that only consume a package-runtime client don't need it at all, and can compile the generated code with any TypeScript, including 7.
+Apps that only consume a generated client don't need it at all, and can compile the generated code with any TypeScript, including 7.
This package is the engine behind the [`generate-client` command](https://redocly.com/docs/cli/commands/generate-client) — install [`@redocly/cli`](https://www.npmjs.com/package/@redocly/cli) to run it from the command line or `redocly.yaml`.
How to use the generated client — auth, middleware, retries, pagination, Server-Sent Events, and the add-on generators (`zod`, `tanstack-query`, `swr`, `mock`, `transformers`) — is documented in [Use the generated client](https://redocly.com/docs/cli/guides/use-generated-client).
@@ -41,8 +41,7 @@ For type-safe authoring of a standalone options object, annotate it with `satisf
The generated module exports its operation descriptors, so an app can build additional instances with independent configuration and credentials over the same generated code:
```ts
-import { createClient } from '@redocly/client-generator';
-import { OPERATIONS, type Ops } from './client.ts';
+import { createClient, OPERATIONS, type Ops } from './client.ts';
const internal = createClient(OPERATIONS, {
serverUrl: 'https://api.example.com',
@@ -50,8 +49,6 @@ const internal = createClient(OPERATIONS, {
});
```
-With `runtime: 'package'` the generated client also imports its whole engine from this package (instead of embedding it), so engine fixes arrive via `npm update` — install this package as a regular dependency of the consuming app.
-
### Write a custom generator
A custom generator reads the same API model the built-ins consume, runs in the same pass, and returns files.
@@ -65,7 +62,7 @@ import { tsType } from '@redocly/client-generator/generate';
export default defineGenerator({
name: 'response-map',
requires: ['typescript'],
- run({ model, outputPath }) {
+ run({ model, output }) {
const printer = new Printer();
// One `ResponseShapes` entry per operation with a JSON success body.
printer.block(
@@ -80,7 +77,7 @@ export default defineGenerator({
},
'};'
);
- return [{ path: outputPath.replace(/\.ts$/, '.responses.ts'), content: printer.toString() }];
+ return [{ path: output.path.replace(/\.ts$/, '.responses.ts'), content: printer.toString() }];
},
});
```
@@ -116,7 +113,7 @@ type GenerateClientResult = {
### `collectGeneratedFiles`
Runs the configured generators against a built model and returns the files in memory, without writing to disk.
-Imported from `@redocly/client-generator/generate` — the generation-time entry; the package root stays runtime-only so package-mode clients never load the generator stack:
+Imported from `@redocly/client-generator/generate` — the generation-time entry; the package root stays a small authoring surface:
```ts
function collectGeneratedFiles(
@@ -133,7 +130,7 @@ function collectGeneratedFiles(
### `defineGenerator`
-Authors a custom generator (`{ name, run }` plus optional `requires`/`errorModes`/`dateTypes`/`runtimes` compatibility metadata, validated up front):
+Authors a custom generator (`{ name, run }` plus optional `requires`/`errorModes`/`dateTypes` compatibility metadata, validated up front):
```ts
function defineGenerator(generator: CustomGenerator): CustomGenerator;
@@ -157,20 +154,9 @@ function defineClientSetup(setup: {
A setup module may import only from `@redocly/client-generator`, so it never adds a dependency to the client (the import is stripped at generation time).
-### `createClient`
-
-The runtime factory that `runtime: 'package'` clients import, also usable directly to build extra instances over generated descriptors (see [Basic usage](#build-extra-client-instances)):
-
-```ts
-function createClient(
- operations: Record,
- config?: ClientConfig
-): Client;
-```
-
## Examples
-Runnable examples — from a zero-install quickstart to middleware, publisher setup, SSE streaming, pagination, custom generators, and the package runtime — live in [`tests/e2e/generate-client/examples`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples).
+Runnable examples — from a zero-install quickstart to middleware, publisher setup, SSE streaming, pagination, and custom generators — live in [`tests/e2e/generate-client/examples`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples).
Each is a standalone Vite app with a checked-in, drift-checked generated client.
## Documentation
@@ -191,4 +177,4 @@ npm run unit # unit tests (this package is held at 100% cover
VITEST_SUITE=e2e npx vitest run tests/e2e/generate-client/ # behavioral e2e
```
-The client runtime lives in `src/runtime/` (real, unit-testable modules; package mode imports them, inline mode embeds them), the structural emitters in `src/emitters/`, the IR in `src/intermediate-representation/`, the generators in `src/generators/`, and the file-layout writers in `src/writers/`.
+Each generator that embeds a runtime keeps its sources in its own folder (`src/generators//runtime/` — real, unit-testable modules that generation embeds), the IR lives in `src/intermediate-representation/`, and the generators in `src/generators/`.
diff --git a/packages/client-generator/docs/adr/0020-self-contained-generator-folders.md b/packages/client-generator/docs/adr/0020-self-contained-generator-folders.md
new file mode 100644
index 0000000000..ebf42f7924
--- /dev/null
+++ b/packages/client-generator/docs/adr/0020-self-contained-generator-folders.md
@@ -0,0 +1,62 @@
+# ADR 0020: Self-contained generator folders, ejected as source
+
+- Status: Accepted
+- Date: 2026-08-21
+
+## Context
+
+Built-in generators have two incompatible shapes, and `redocly eject-generator` papers over the difference.
+
+`python`, `go`, and `php` are each one self-contained file that imports only the neutral toolkit.
+Ejecting one type-strips its own source, so the user reads what we wrote.
+The other seven — `typescript`, `zod`, `mock`, `swr`, `tanstack-query`, `transformers`, `cli` — are thin entries over shared `emitters/` modules.
+Ejecting one **esbuild-bundles about 24 modules**: the result opens with `__defProp`/`__name` shims, ends with a renamed `entry_typescript_default`, and inlines copies of `authoring/printer.ts`, `authoring/schema.ts`, `authoring/pagination.ts`, and `authoring/reference-page.ts` — code that is already public API and should have been imported.
+At 178 kB it is compiler output, not a file anyone owns.
+
+Three further problems follow from the split:
+
+1. **The import rewrite is a string swap.** The eject build does `.replaceAll("'../../authoring/index.js'", "'@redocly/client-generator'")`. Nothing stops a generator from deep-importing `../../authoring/schema.js` or any private emitter, which would silently ship a broken eject.
+2. **The two shapes hide that the generators are the same pipeline.** `pythonGenerator` emits header → models → servers → embedded runtime → descriptor table → client class. `emitClient` emits header → schema statements → servers → embedded runtime → ops wiring → descriptor table → client section. Eleven stages line up 1:1. The difference is organizational drift, not architecture.
+3. **`emitters/` mixes three unrelated things** — one generator's body, genuinely shared syntax helpers, and IR analysis that contains no TypeScript at all (see [`../helper-surface.md`](../helper-surface.md)).
+
+Measured at symbol level, the seven TypeScript generators share **four functions totalling 27 lines** (`safeIdent`, `pascalCase`, `codeLiteral`, `codeString`).
+The rest of `emitters/` is single-owner.
+The fear that self-contained generators would duplicate a large shared TypeScript layer is not supported by the code.
+
+## Decision
+
+**Every generator is a self-contained folder, and ejecting copies that folder as TypeScript source.**
+
+1. **One skeleton for every language.** A generator folder is `AGENTS.md`, `index.ts` (`run`/`sample`/`docs`/`options`), and one file per pipeline stage: `naming`, `types`, `models`, `descriptor`, `operations`, `pagination`, `client`, plus `runtime/` where the generator embeds one.
+ The skeleton is **descriptive, not prescriptive** — a language omits a stage it does not have (python has no `split`, zod has no `client`), and there are no empty placeholder files.
+ An agent that has read `generators/python/` can navigate `generators/typescript/` without re-learning.
+2. **The single-file generators are refactored into the same shape — they are not grandfathered.**
+ `python`, `go`, and `php` are already self-contained, but self-containment was never the goal on its own: the uniform skeleton is what makes generators comparable, navigable, and reviewable.
+ A 953-line `python/index.ts` and a 1169-line `go/index.ts` are past the size anyone holds at once, and leaving them whole would keep exactly the asymmetry this ADR removes — one language you read as a folder, another you read by scrolling.
+ The refactor is a **re-grouping, not a rewrite**: python's existing functions already sort into the stages cleanly — `className`/`fieldName`/`operationIdents` into `naming`, `pythonType` into `types`, `writeDataclass`/`renderPythonModels`/`pydanticDiscriminators` into `models`, `securitySpecs`/`paginationSpec`/`envelopeHeaderSpecs` into `descriptor`, `writeMethod` into `operations`, `writePaginationWrappers` into `pagination`, `writePythonServers`/`writeClientClass` into `client`.
+ Go and PHP sort the same way.
+3. **`emitters/` is dissolved.** Each module moves to the generator that owns it, to a language printer ([ADR-0021](./0021-text-printers.md)), or to the neutral toolkit.
+4. **Three import rules, enforced by a guard test.** A generator folder may import only its own files, `@redocly/client-generator`, `@redocly/client-generator/printers/`, `@redocly/client-generator/runtime-sources`, and the contract of a generator it `requires`.
+ No relative import may leave the folder.
+ `language-dogfooding.test.ts` generalizes from three generators to all ten.
+5. **Package specifiers in source, resolved by `paths`.** Source imports the same specifier the ejected file does; a tsconfig `paths` entry maps it to `src/` for typechecking.
+ The `replaceAll` rewrite is deleted, and the source/ejected import lines become byte-identical.
+6. **Sharing has four tiers, and only four.** The neutral toolkit (IR analysis, contract types, `Printer`); the language printer (syntax); `runtime-sources`; and a required generator's published **contract**.
+ `contracts/typescript` exports the generated SDK's ABI — `operationSignature`, `variablesName`, `sdkCallText`, `wrappableOperations`, `flatInputShape` — for the generators that declare `requires: ['typescript']`.
+ A generator may never import another generator's internals.
+7. **Eject copies the folder as `.ts`.** No esbuild, no bundling, no synthesized entry module, no import rewriting.
+ The descriptor default export is still appended from `BUILTIN_META`, which keeps `meta.ts`'s laziness intact.
+ `--update` merges per file with the three-way merge already used for skills.
+8. **Ejected `.ts` requires a Node floor check at the point of use.** Built-in generators compile to `lib/*.js` and are unaffected; only an ejected folder is TypeScript.
+ The resolver checks the running Node version when an entry resolves to a `.ts` file and errors with the required version.
+
+## Consequences
+
+- A user who ejects `typescript` owns eight readable files averaging about 200 lines instead of one 178 kB bundle. Ejected code is the code we wrote, in every language.
+- Ejected generators keep full type checking against the IR's 273 lines of model types. An agent editing an ejected generator gets errors at edit time rather than at generation time — the largest single agent-affordance in this plan.
+- A `--update` conflict lands in one stage file instead of anywhere in an 1800-line bundle.
+- The four sharing tiers are mechanically checkable, so "accidentally imported something not exposed" stops being possible rather than becoming a review item.
+- **Cost: a large mechanical migration.** Thirty-plus modules move, and python, go, and php each split from one ~1000-line file into about eight. The diff is enormous and mostly moves.
+- **Cost: ejecting requires a newer Node.** Anyone on the current floor who ejects gets a clear error instead of a working generator until they upgrade.
+- **Cost: `contracts/typescript` is a new public surface** to version and document. It is the honest name for a dependency that already exists — `swr` and `tanstack-query` already code against the TypeScript SDK's calling convention — but naming it makes it a compatibility obligation.
+- Divergence between an ejected generator and a package-side assumption stays possible. The `requiresGenerator` range already in the ejected descriptor is the place to extend a contract-version check.
diff --git a/packages/client-generator/docs/adr/0021-text-printers.md b/packages/client-generator/docs/adr/0021-text-printers.md
new file mode 100644
index 0000000000..858cab161e
--- /dev/null
+++ b/packages/client-generator/docs/adr/0021-text-printers.md
@@ -0,0 +1,61 @@
+# ADR 0021: Text printers — one common printer plus one per language
+
+- Status: Accepted
+- Date: 2026-08-21
+- Supersedes: [ADR-0001](./0001-ast-codegen.md)
+
+## Context
+
+[ADR-0001](./0001-ast-codegen.md) chose `ts.factory` AST codegen and is still marked Accepted, but the code has not worked that way for some time: `emitters/ts.ts` and `emitters/package-client.ts` no longer exist, and every built-in generator emits text.
+That migration happened because the generator gained non-TypeScript output languages, and an AST for TypeScript does nothing for Python, Go, or PHP.
+This ADR records the shape the code actually has, and settles what belongs in a shared printer.
+
+The text layer today is inconsistent in ways that are more than cosmetic — the full inventory is in [`../helper-surface.md`](../helper-surface.md), and the load-bearing findings are:
+
+- **Two identifier systems.** `authoring/naming.ts` states it in its own header: _"TypeScript keeps its specialized sanitizer in emitters/identifier.ts; this is for the other output languages."_ The TypeScript reserved-word list exists twice, and the two systems disagree on convention — `sanitizeIdentifier` prefixes (`_class`), `identifierFor` suffixes (`class_`).
+- **Two TypeScript string escapers with different security policies.** `codeString` escapes U+2028/U+2029; `sanitizeCodeString` also escapes `<`/`>` to prevent a `` breakout. Which protection applies depends on which one the caller imported.
+- **Python and Go have no string escaper at all** — 19 and 28 raw `JSON.stringify` calls respectively, relying on JSON escaping being close enough to each language's literal syntax.
+- **Four hand-rolled doc-comment writers**, each re-deriving real per-language rules: Go collapses consecutive blank comment lines because gofmt rewrites `//\n//`; TypeScript must escape `*/` because `info.title` is attacker-controllable; Python has distinct one-line and multi-line docstring forms; PHP needs `@tag` lines because its type syntax erases element types.
+- **Indent units are passed at call sites** — `new Printer(' ')`, `new Printer('\t')`.
+
+Two alternatives were considered and rejected.
+
+**Prettier's Doc IR** (the Wadler/Oppen algebra behind `group`/`line`/`indent`) would buy automatic line-width breaking, which is a genuine gap — generated output is hand-formatted with no post-pass.
+It was rejected because `group([indent([line, …])])` hides the emitted text, and Prettier's own architecture argues against it here: Prettier has no universal syntax model either, only a universal _layout_ engine plus a hand-written printer per language.
+Its printers run to thousands of lines because they must handle every possible program; ours emit roughly fifteen constructs per language.
+We do not have Prettier's problem.
+
+**Tree-sitter** is a parser with no unparser, and there is no universal AST or codegen spec to adopt (UAST is dead; srcML covers a few C-family languages).
+
+**Delegating to real formatters** (prettier, black, gofmt, php-cs-fixer) was rejected because those tools are not available in a Node CLI, so formatting would depend on what is on `PATH` — breaking the determinism rule that the same description produces the same bytes.
+
+## Decision
+
+**Generated code is text, built by a common structural printer plus one syntax printer per output language.**
+
+1. **The common `Printer` owns structure only** — `line`, `blank`, `lines`, `indent`, `block`, `toString` — and stays in the neutral toolkit.
+2. **A language printer extends it with syntax**, one per output language, with a common core: `typeName`, `memberName`, `identifier`, `identifiers`, `string`, `literal`, `comment`, `doc`, a baked-in `indentUnit`, and a `layout(source)` pass that `toString()` applies.
+ `identifier` is spelled out rather than abbreviated; `ident` is too easily misread as `indent` at the call sites where both appear.
+3. **The boundary is syntax versus shape.** The printer owns identifier safety, string escaping, literal rendering, comment and doc syntax, indentation, and whole-file layout.
+ The generator owns everything that decides output shape — classes, functions, signatures, field lists — written as template literals.
+ The test for whether a method belongs on the printer: **is there exactly one right answer?**
+ `py.string("it's")` has one. `py.dataclass(name, fields)` has a hundred (frozen? slots? kw_only?), which makes it a design decision, and design decisions must stay visible in the generator.
+4. **The boundary is enforced, not agreed.** A guard test asserts each generator's source still contains the literal keywords it emits, so an agent asked to make dataclasses frozen finds `@dataclass` on a line and edits it, rather than needing to read a printer that is not in the ejected folder.
+5. **Per-language extensions are kept, not flattened.** Only TypeScript has quotable object keys (`key`); only PHP needs doc `tags`; only Go needs `layout` and an exported-ness rule; only Python needs `memberName` to report that it renamed, for `_field_map`.
+ Forcing a lowest common denominator would lose real language knowledge — notably Go's `_`→`N` rule, where `identifierFor`'s `_` prefix for a digit-leading name means **unexported**, so `encoding/json` would silently skip the field.
+6. **The duplicates collapse.** One TypeScript reserved-word list, one TypeScript string escaper (on the stricter policy), one doc-comment path per language.
+
+`layout()` exists because Go demands byte-exact `gofmt` output: CI commonly runs `gofmt -l` and fails on any file it would reformat, and column alignment cannot be computed line-by-line — the padding for the first field depends on the longest field in a run that has not been emitted yet.
+It is the identity function for TypeScript, Python, and PHP.
+
+## Consequences
+
+- Four printers fill the same six slots, which is the check that the abstraction is real rather than a bag of leftovers.
+- The security-relevant escaping (`*/` in JSDoc, `<`/`>` in code strings, quoting in every language) is applied by construction instead of being a rule each generator author must have read.
+- Python and Go gain a defined string-escaping policy where they had none.
+- Ejected generators still read as the language they emit: `class`, `@dataclass`, `ClassVar[Dict[str, str]]` remain literal text in the file the user owns.
+- **Three behavior changes move output bytes** — a real `string()` for Python and Go (47 call sites), the stricter merged TypeScript escaper, and unifying the two pagination resolvers.
+ All three are in scope for this rewrite rather than deferred, since the package is experimental ([ADR-0013](./0013-experimental-status.md)) and each fixes a defect rather than merely relocating code.
+ Each lands with its own tests and snapshot updates, so a byte change is reviewed as a behavior change and not lost inside a large move.
+- **Cost: no automatic line-width breaking.** Long union types, signatures, and argument lists stay hand-wrapped. If that becomes a real complaint, the surgical fix is a `wrap()` helper for the few constructs that run long, not a change in how code is represented.
+- ADR-0001's warning survives its decision and still applies: the printer is not a sanitizer. Names are coerced in the IR (`intermediate-representation/sanitize-identifiers.ts`) and comment text is escaped by `doc()`. Any new value flowing into an identifier slot or a comment needs the same handling.
diff --git a/packages/client-generator/docs/adr/0022-runtime-inline-or-module.md b/packages/client-generator/docs/adr/0022-runtime-inline-or-module.md
new file mode 100644
index 0000000000..282ee50ea0
--- /dev/null
+++ b/packages/client-generator/docs/adr/0022-runtime-inline-or-module.md
@@ -0,0 +1,53 @@
+# ADR 0022: Runtime distribution is inline or a sibling module; package mode is removed
+
+- Status: Accepted
+- Date: 2026-08-21
+- Amends: [ADR-0017](./0017-runtime-module-and-descriptor-client.md) (point 3)
+
+## Context
+
+[ADR-0017](./0017-runtime-module-and-descriptor-client.md) made the runtime a hand-written module and offered two distributions: `inline` (default — the runtime embedded in the generated file, preserving [ADR-0002](./0002-typescript-peer-dep.md)'s zero-dependency promise) and `package` (the client imports `@redocly/client-generator`, so runtime fixes arrive by `npm update` with no regeneration).
+
+Making generators self-contained, ejectable folders ([ADR-0020](./0020-self-contained-generator-folders.md)) puts package mode in direct conflict with the rest of the architecture, in five places:
+
+1. **It contradicts the package's headline.** Package mode is the one mode in which the generated client has a dependency.
+2. **It is the sole reason the root entry is constrained.** `entry-weight.test.ts` exists only because package-mode clients import the package root at app runtime — that is what forces the root free of `typescript`, `openapi-core`, and Node builtins, and forces `generateClient` to reach the pipeline through a dynamic import.
+3. **It creates a silent-divergence trap.** Once a user ejects the generator and edits `runtime/retry.ts`, inline mode picks the change up and package mode does not — with no diagnostic. `PACKAGE_SPECIFIER` is a hardcoded const in `client-assembly.ts`, so their runtime is not reachable at all.
+4. **It forces the TypeScript runtime to be dual-purpose** — both the text embedded into generated clients and the package's own exported runtime — which is the one thing blocking the runtime from living inside its generator's folder.
+5. **It is an axis in the generator contract.** `runtimes?: ('inline' | 'package')[]` is declared per generator and checked by `validateGenerators`; php declares it does not support package mode.
+
+Package mode's real purpose is deduplication: do not inline about 1500 lines into every client.
+That purpose does not require npm.
+
+A related finding is that inline mode, not module mode, is the one carrying machinery.
+`assembleInlineRuntime` embeds `RUNTIME_SOURCES_STRIPPED` — modules with their syntax removed so they can concatenate into one file — and `pythonGenerator` strips `from __future__` lines and every intra-runtime `from ._x` import for the same reason.
+A sibling `runtime/` folder needs none of that: the real sources are written as they are, imports intact.
+
+## Decision
+
+**`runtime` is `'inline' | 'module'`. Package mode is removed.**
+
+1. **`inline` stays the default** — one self-contained file with the runtime embedded, exactly as today.
+2. **`module` writes the runtime as real files in a `runtime/` folder** beside the generated client, which imports it relatively.
+ Only the modules the API needs are written; the capability-seam assembly from [ADR-0017](./0017-runtime-module-and-descriptor-client.md) point 4 is unchanged, and the generated `createClient` factory becomes a file in that folder rather than a concatenated block.
+3. **Both modes are available for every generator that embeds a runtime** — `typescript`, `python`, `go`, `php`, `cli`.
+ Module mode is more idiomatic than inline for two of them: Python's runtime is naturally `_send.py`, `_auth.py`, …, and Go packages span files by design.
+4. **The runtime moves into its generator's folder** — `generators/typescript/runtime/*.ts`, `generators/python/runtime/*.py`, `generators/go/runtime/runtime.go`, `generators/php/runtime/runtime.php`, `generators/cli/runtime/cli.ts`.
+ Generators that embed no runtime (`zod`, `mock`, `swr`, `tanstack-query`, `transformers`) have no `runtime/` folder; `swr` and `tanstack-query` emit hooks that import the generated SDK module, so there is nothing for them to embed.
+5. **The `runtimes` field leaves the generator contract**, along with the `--runtime package` CLI choice and its validation path.
+6. **The root entry stops exporting the client runtime.** `createClient`, `ApiError`, `TimeoutError`, `mergeSetup`, `defaultRetryOn`, `runCli`, `invokedName`, and the runtime's type surface are removed — package mode was their reason for being public, and nothing imports the package root at app runtime any more.
+ The root keeps the authoring toolkit, the plugin API, the user-facing config types, and the setup contract.
+7. **The setup contract moves up a layer.** `runtime-contract.ts` today re-exports `Middleware`, `RequestContext`, and `RetryConfig` _from_ `runtime/types.ts`, deliberately, so a publisher's `--setup` file cannot drift from the generated output ([ADR-0015](./0015-publisher-setup-bake-in.md)).
+ With the runtime inside a generator folder, that direction would make the package root reach into `generators/typescript/`, so it inverts: the contract types are defined at package level and the TypeScript runtime imports them.
+ One definition either way — ownership moves from the runtime to the contract, which is the layer users actually author against.
+
+## Consequences
+
+- The self-contained folder structure becomes possible: no dual-purpose runtime, no re-export from the root into a generator folder, no top-level `runtime/` directory.
+- The silent-divergence trap is gone. Whatever is in the user's `runtime/` folder **is** the runtime, in both modes.
+- **`entry-weight.test.ts` is deleted, not relaxed.** With no app-runtime consumer of the package root, the rule it enforced — no `typescript`, no `openapi-core`, no Node builtins in the root's static graph — stops existing, and the dynamic `import('./pipeline.js')` inside `generateClient` is no longer forced by it.
+ The root entry becomes what it should have been: the authoring surface.
+- Module mode gives package mode's deduplication without npm, without publishing, and while staying zero-dependency — and it needs no source stripping.
+- **Cost: this is a breaking change for `runtime: 'package'` users**, and it withdraws the benefit ADR-0017 led with. Those users lose the `^`-range channel for runtime fixes and must regenerate instead — one command, but not nothing. The package is experimental at 0.x ([ADR-0013](./0013-experimental-status.md)) and the default was always `inline`, which bounds the blast radius; module mode is the migration path.
+- **Cost: anyone importing the client runtime from the package root breaks.** That was package mode's surface, but it was public, and there is no deprecation window — the experimental status is doing the work here.
+- The `--setup` contract keeps working: `bakeSetup` already strips the package import, so `defineClientSetup` and its types stay a compile-time-only surface ([ADR-0015](./0015-publisher-setup-bake-in.md)) — now defined at package level rather than re-exported from the runtime.
diff --git a/packages/client-generator/docs/adr/README.md b/packages/client-generator/docs/adr/README.md
index 9df55a34de..1aed6e58cd 100644
--- a/packages/client-generator/docs/adr/README.md
+++ b/packages/client-generator/docs/adr/README.md
@@ -11,7 +11,7 @@ ARCHITECTURE.md says _what is_; these ADRs say _why_.
| # | Decision | Status |
| ------------------------------------------------------ | ----------------------------------------------------------------- | ----------------------- |
-| [0001](./0001-ast-codegen.md) | Generate TypeScript via the TS AST (`ts.factory`), not strings | Accepted |
+| [0001](./0001-ast-codegen.md) | Generate TypeScript via the TS AST (`ts.factory`), not strings | Superseded by 0021 |
| [0002](./0002-typescript-peer-dep.md) | `typescript` as a peer dep; zero-runtime-dependency output | Accepted |
| [0003](./0003-spec-agnostic-ir.md) | A spec-agnostic IR as the builder↔emitter contract | Accepted |
| [0004](./0004-registry-seams.md) | First-party `getGenerator` / `getWriter` registry seams | Accepted |
@@ -27,9 +27,12 @@ ARCHITECTURE.md says _what is_; these ADRs say _why_.
| [0014](./0014-request-response-customization.md) | Request/response customization as a runtime contract | Accepted |
| [0015](./0015-publisher-setup-bake-in.md) | Publisher setup bake-in via `--setup` | Accepted |
| [0016](./0016-msw-generator-vs-mock-server.md) | In-process MSW mocks coexist with the out-of-process mock server | Accepted |
-| [0017](./0017-runtime-module-and-descriptor-client.md) | Hand-written runtime module + descriptor-driven generated clients | Accepted |
+| [0017](./0017-runtime-module-and-descriptor-client.md) | Hand-written runtime module + descriptor-driven generated clients | Amended by 0022 |
| [0018](./0018-auto-pagination.md) | Auto-pagination as declared, statically verified configuration | Accepted |
| [0019](./0019-first-class-client-config.md) | `generate-client` config via a first-class `client` block | Accepted |
+| [0020](./0020-self-contained-generator-folders.md) | Self-contained generator folders, ejected as source | Accepted |
+| [0021](./0021-text-printers.md) | Text printers — one common printer plus one per language | Accepted |
+| [0022](./0022-runtime-inline-or-module.md) | Runtime is inline or a sibling module; package mode removed | Accepted |
## Template
diff --git a/packages/client-generator/docs/helper-surface.md b/packages/client-generator/docs/helper-surface.md
new file mode 100644
index 0000000000..adb36d0451
--- /dev/null
+++ b/packages/client-generator/docs/helper-surface.md
@@ -0,0 +1,252 @@
+# Helper surface — pre-rewrite analysis
+
+A complete inventory of the helper code in `@redocly/client-generator`, taken before the
+generator-folder rewrite.
+This says **what exists today, who uses it, and where it belongs** — so the rewrite moves
+code with evidence rather than intuition.
+
+It is a point-in-time analysis, not a living document.
+Once the rewrite lands, [`../ARCHITECTURE.md`](../ARCHITECTURE.md) is the descriptive map and this file can go.
+
+## Method
+
+Measurements below come from static analysis of `src/`, excluding `__tests__`:
+
+- **Reachability** — value imports (type-only imports are erased at runtime) followed transitively from each generator entry.
+- **Direct symbol use** — `import { … } from …` bindings, attributed to the generator that owns the importing module.
+- **Toolkit use** — identifier occurrences of each `AUTHORING_HELPER_NAMES` entry outside `authoring/`.
+
+Totals: **87 files, 15,913 lines, 183 exported values, 107 exported types.**
+
+Reachability overstates sharing (a module reached through three hops is not a shared helper), so
+every claim below is based on direct symbol use.
+
+## Headline: there are two parallel toolkits
+
+`authoring/` is documented as "the language-neutral authoring toolkit — pure functions over the IR".
+In practice it is **the toolkit the three non-TypeScript generators use.**
+The TypeScript family has a complete shadow implementation in `emitters/`.
+
+| Concern | Neutral toolkit (`authoring/`) | TypeScript shadow (`emitters/`) |
+| ---------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| Identifiers | `identifierFor`, `uniqueIdentifiers`, `RESERVED_WORDS` | `sanitizeIdentifier`, `uniqueIdent`, `safeIdent`, `isIdentifier`, `isSafeIdentifier`, `TS_RESERVED` |
+| Text building | `Printer` | `[…].join('\n')` arrays |
+| Description text | `docText` | `splitLines`, `jsdocText` |
+| Comment escaping | — | `escapeJsDoc` |
+| Schema shape | `isNullable`, `unwrapNullable`, `flattenAllOf`, `enumValues`, `discriminatorCases` | inline in `ts-type.ts` |
+| Pagination | `paginationRuleFor` | `resolveOperationPagination`, `resolveModelPagination` |
+| Casing | `casing.pascal` | `pascalCase` |
+
+Consumers of each neutral helper, counted outside `authoring/`:
+
+| Helper | Consumers |
+| --------------------- | --------------------------------------------- |
+| `NotSupportedError` | 9 — package-wide error type, genuinely shared |
+| `Printer` | go, php, python, `cli-docs` (Markdown) |
+| `renderReferencePage` | go, php, python, **typescript** |
+| `schemaAtPointer` | go, php, python, `pagination` |
+| `headerCoerceType` | go, php, python, `response-headers` |
+| `casing` | go, `cli`, `runtime/cli`, `runtime-sources` |
+| `identifierFor` | go, php, python |
+| `uniqueIdentifiers` | go, php, python |
+| `RESERVED_WORDS` | go, php, python |
+| `flattenAllOf` | go, php, python |
+| `discriminatorCases` | go, php, python |
+| `isNullable` | go, php, python |
+| `unwrapNullable` | go, php, python |
+| `enumValues` | go, php, python |
+| `docText` | go, php, python |
+| `paginationRuleFor` | go, php, python |
+
+**Ten of sixteen neutral helpers have exactly three consumers, and they are always the same three.**
+No TypeScript-family generator uses `Printer`, `docText`, `identifierFor`, or any of the schema-shape
+helpers.
+The neutral toolkit is not neutral in practice — it is the non-TypeScript toolkit, and TypeScript
+duplicates it.
+
+## What TypeScript generators actually share with each other
+
+Measured at symbol level across all seven TypeScript-family generators (`typescript`, `zod`, `mock`,
+`swr`, `tanstack-query`, `transformers`, `cli`), excluding each generator's own modules.
+
+**Genuinely TypeScript-specific and shared — four functions, 27 lines:**
+
+| Symbol | Module | Lines | Used by |
+| ------------- | --------------- | ----- | --------------------------------------------------- |
+| `safeIdent` | `identifier.ts` | 6 | mock, tanstack-query, transformers, zod, typescript |
+| `pascalCase` | `support.ts` | 3 | mock, swr, transformers, zod, typescript |
+| `codeLiteral` | `ts-literal.ts` | 13 | typescript, mock, zod |
+| `codeString` | `identifier.ts` | 5 | typescript, tanstack-query |
+
+**Shared but not TypeScript-specific** — the generator contract and output plumbing:
+`Generator` (7×), `anchor` (7×), `HEADER` (7×), `CodeSample`/`SampleContext` (2×), `DateType` (2×).
+
+**Shared but IR analysis, misfiled into `emitters/`:**
+`isSseOp` (3×), `resolveModelPagination` (2×), `PaginationConfig` (2×).
+
+**Used by the `typescript` generator alone** — the "shared TypeScript emitter layer" is largely a
+myth; this is one generator's body living in a shared directory:
+`tsType`, `tsJsdoc`, `renderTypeAliases`, `operationSignature`, `templatePathParams`, `descriptor`,
+`type-guards`, `reserved-names`, `response-headers`, `inline-runtime`, `runtime-sources`,
+`render-client`, `client-assembly`.
+
+**Cross-generator edges — exactly two in the entire package:**
+`cli → typescript` for `embedCliRuntime` and `flatInputShape`.
+Both lie along the `requires: ['typescript']` edge `cli` already declares.
+
+**One two-generator cluster:** `wrapper-support.ts` (98 lines — `wrappableOperations`, `isQuery`,
+`hasInputs`, `variablesName`, `sdkCallText`, `sdkNamedImportText`), used by `swr` and
+`tanstack-query` only.
+This is not incidental overlap: it is the **ABI of the generated TypeScript SDK**, derived from
+`operationSignature`, which `typescript` owns.
+
+## Duplications and conflicts
+
+Twelve concrete defects, each verifiable in the current source.
+
+| # | Finding | Evidence |
+| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
+| 1 | **Two identifier systems.** `authoring/naming.ts` says so in its own header: _"TypeScript keeps its specialized sanitizer in emitters/identifier.ts; this is for the other output languages."_ | `authoring/naming.ts:1-4` |
+| 2 | **`TS_RESERVED` is duplicated.** Two 44-word lists that must be hand-synced. | `emitters/identifier.ts` vs `RESERVED_WORDS.typescript` |
+| 3 | **Opposite reserved-word conventions.** `sanitizeIdentifier` prefixes (`_class`); `identifierFor` suffixes (`class_`). Same problem, two answers, split by language accidentally. | `identifier.ts:76`, `naming.ts:82` |
+| 4 | **Two TypeScript string escapers with different security policies.** `codeString` escapes U+2028/U+2029; `sanitizeCodeString` also escapes `<`/`>` to stop a `` breakout. Which protection applies depends on which one the caller imported. | `identifier.ts:87`, `ts-literal.ts:21` |
+| 5 | **Python and Go have no string escaper.** They call `JSON.stringify` inline — **19 sites in python, 28 in go** — relying on JSON escaping being close enough to Python and Go literal syntax. No policy, no test. | `python/index.ts`, `go/index.ts` |
+| 6 | **Two pagination resolvers implementing the same three-source precedence.** `paginationRuleFor` (declaration-only) and `resolveOperationPagination` (verifies fit, reports errors). Python goes through one, TypeScript the other — **they can disagree about whether an operation paginates.** | `authoring/pagination.ts`, `emitters/pagination.ts` |
+| 7 | **Four hand-rolled doc-comment writers**, each re-deriving real per-language subtleties. | `writeDocstring` (py), `writeDocComment` (go), `writeDocComment` (php), `renderTitleComment` (ts) |
+| 8 | **TypeScript syntax inside a "neutral" const.** `HEADER` is a hardcoded `//` comment, which is why `pythonGenerator` hand-writes its own `#` header. | `emit-options.ts:13` |
+| 9 | **Indent units passed at call sites.** `new Printer(' ')`, `new Printer('\t')` — invisible in review. | `python/index.ts:234`, `go/index.ts:172` |
+| 10 | **`anchor` is a four-line `path.parse` wrapper** used by all seven TypeScript generators; python re-implements it as `pythonModulePath`. | `generators/anchor.ts`, `python/index.ts:806` |
+| 11 | **ADR-0001 and ARCHITECTURE.md describe deleted code.** Both document `ts.factory` AST codegen via `emitters/ts.ts` and `emitters/package-client.ts`. Neither module exists; every generator emits text. `jsdoc.ts` still refers the reader to `ts.ts`'s helper. | `docs/adr/0001`, `ARCHITECTURE.md`, `jsdoc.ts:12` |
+| 12 | **`flatInputShape` contains no TypeScript.** It takes `OperationModel` + `NamedSchemaModel[]`, counts names, returns a verdict. It is TypeScript-only because it lives in `render-client.ts`. Python, Go, and PHP each re-derive the same collision question via `uniqueIdentifiers(…, { taken: METHOD_ARG_SLOTS })`. | `render-client.ts:174`, `python/index.ts:509`, `go/index.ts:494`, `php/index.ts:550` |
+
+Findings 4, 5, and 6 are correctness or security issues, not tidiness.
+
+## Where each helper lands
+
+Five destinations.
+The rule: **facts belong on the data, syntax belongs on the printer, shape belongs to the generator.**
+
+### 1. Neutral toolkit — `@redocly/client-generator`
+
+Language-agnostic analysis over the IR, plus the authoring contract.
+
+| Keep | Add (re-homed from `emitters/`) |
+| ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
+| `Printer` (structure only), `casing`, `identifierFor`, `uniqueIdentifiers`, `RESERVED_WORDS` | `inputNameCollisions` — the neutral half of `flatInputShape` |
+| `flattenAllOf`, `discriminatorCases`, `isNullable`, `unwrapNullable`, `enumValues`, `schemaAtPointer`, `headerCoerceType` | — |
+| `docText`, `renderReferencePage`, `NotSupportedError` | — |
+| `Generator`, `GeneratorInput`, `CodeSample`, `SampleContext`, `DateType` | — |
+
+**Removed by becoming data rather than helpers:**
+
+| Helper | Becomes |
+| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
+| `isSseOp`, `eventSchema`, `sseDataKind` | `op.sse?: { eventSchema?, dataKind }` — computed once by the IR builder |
+| `paginationRuleFor` + `resolveModelPagination` + `resolveOperationPagination` | **one** resolver, run once by the pipeline → `input.pagination` |
+| `anchor` | `input.output: { path, dir, stem, ext }` |
+| `HEADER`, `banner`, `renderTitleComment` | `input.banner: string[]` (content) + `printer.doc()` (syntax) |
+
+### 2. Language printers — `@redocly/client-generator/printers/`
+
+Syntax mechanics with exactly one right answer.
+The boundary: **the printer owns syntax, the generator owns shape.**
+No `class()`, `func()`, `method()`, or `signature()` helpers — those stay template literals so the
+emitted code remains visible to whoever edits the generator next.
+
+Common core: `typeName`, `memberName`, `identifier`, `identifiers`, `string`, `literal`, `comment`,
+`doc`, plus a `layout(source)` pass and a baked-in `indentUnit`.
+
+| Printer | Absorbs | Language-specific extension |
+| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
+| `TypeScriptPrinter` | `pascalCase`, `safeIdent`, `uniqueIdent`, `sanitizeIdentifier`, `codeString` + `sanitizeCodeString` (merged on the stricter policy), `codeLiteral`, `escapeJsDoc`, `jsdocText`, `splitLines` | `key(name)` — bare-or-quoted object key. No other language has quotable keys. |
+| `PythonPrinter` | `className`, `fieldName`, `pythonLiteral`, `writeDocstring`, `Printer(' ')` | `constName` (SCREAMING_SNAKE); `memberName` reports whether it renamed, for `_field_map` |
+| `GoPrinter` | `exported` (incl. the `_`→`N` rule), `writeDocComment`, `Printer('\t')` | `layout` = `gofmtShape` + `alignGoColumns`; `packageName` validation |
+| `PhpPrinter` | `className`, `propertyName`, `phpString`, `writeDocComment` | `doc` takes `tags` — PHP's `array`/`\Generator` erase element types |
+
+Two notes on the extensions.
+Go's `exported` carries knowledge that must not be re-derived: `identifierFor` prefixes `_` for a
+digit-leading name, and in Go a leading `_` means **unexported**, so `encoding/json` would silently
+skip the field.
+Go's `layout` cannot be done line-by-line — column padding depends on the widest member of a run of
+adjacent lines, which is not known when the first line is emitted.
+
+### 3. Generator-owned — `src/generators//`
+
+Everything that decides output _shape_.
+
+This runs in both directions.
+The TypeScript-family generators **gain** the modules that are theirs alone, as `emitters/` dissolves.
+The single-file generators are **split** into the same stages rather than left whole — `python`
+(953 lines), `go` (1169), and `php` (1092) are self-contained already, but the uniform skeleton is
+what makes them comparable, and their existing functions re-group into it without rewriting:
+
+| Stage | python | go | php |
+| ------------ | ---------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------- |
+| `naming` | `className`, `fieldName`, `operationIdents` | `exported`, `goOperationIdents` | `className`, `propertyName`, `methodName` |
+| `types` | `pythonType` | `goType` | `phpType`, `phpNullable`, `phpUnionType` |
+| `models` | `writeDataclass`, `renderPythonModels`, `pydanticDiscriminators` | `writeStruct`, `renderGoModels` | `writeClass`, `renderPhpModels`, `hydration`, `serialization` |
+| `descriptor` | `securitySpecs`, `paginationSpec`, `envelopeHeaderSpecs` | `goSecurityLiteral`, `goPaginationLiteral` | `phpSecurityLiteral`, `phpPaginationLiteral` |
+| `operations` | `writeMethod` | `writeGoMethod` | `writePhpMethod`, `methodArgs`, `writeRequestSetup` |
+| `pagination` | `writePaginationWrappers` | `writeGoPaginationWrappers` | `writePhpPaginationWrappers` |
+| `client` | `writePythonServers`, `writeClientClass` | `writeGoServers` | `writeServers` |
+
+`emitters/` dissolves entirely:
+
+| Generator | Absorbs |
+| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `typescript` | `client-assembly`, `render-client`, `descriptor`, `type-guards`, `reserved-names`, `response-headers`, `operation-types`, `operations`, `inline-runtime`, `runtime-sources`, `ts-type`, `emit-options` |
+| `zod` | `zod.ts` |
+| `mock` | `mock.ts`, `mock-value.ts`, `faker.ts`, `sample.ts` |
+| `cli` | `cli.ts`, `cli-docs.ts` |
+| `swr` | `swr.ts` |
+| `tanstack-query` | `tanstack-query.ts` |
+| `transformers` | `transformers.ts` |
+
+### 4. Generator contracts — `@redocly/client-generator/contracts/`
+
+A generator's published output ABI, importable **only** along a declared `requires` edge.
+
+`contracts/typescript` exports `operationSignature`, `templatePathParams`, `variablesName`,
+`hasInputs`, `isQuery`, `sdkCallText`, `sdkNamedImportText`, `wrappableOperations`, `flatInputShape`
+— consumed by `swr`, `tanstack-query` (`requires: ['typescript']`), and `cli`
+(`requires: ['typescript', 'zod']`).
+
+Duplicating `wrapper-support` into swr and tanstack-query would put the SDK's ABI in two places,
+which is exactly the drift its own header says it exists to prevent.
+
+### 5. Deleted
+
+`emitters/setup-bake.ts` stays (reached via a dynamic import from `pipeline.ts`), but these go:
+
+- The duplicate `TS_RESERVED` list.
+- One of the two TypeScript string escapers.
+- One of the two pagination resolvers.
+- `anchor.ts`, `sse.ts`, `support.ts`, `jsdoc.ts`, `identifier.ts`, `ts-literal.ts` as standalone modules.
+- **The root entry's client-runtime exports** — `createClient`, `ApiError`, `TimeoutError`,
+ `mergeSetup`, `defaultRetryOn`, `runCli`, `invokedName`, and the runtime type surface
+ ([ADR-0022](./adr/0022-runtime-inline-or-module.md)).
+ The setup contract stays public and moves up a layer: it is defined at package level and the
+ TypeScript runtime imports it, inverting today's `runtime-contract.ts` → `runtime/types.ts`
+ direction so the root never reaches into a generator folder.
+- **`entry-weight.test.ts`** — with no app-runtime consumer of the package root, the constraint it
+ guards stops existing.
+
+## Behavior changes in scope
+
+Three items change output bytes.
+All three are in scope for the rewrite — the package is experimental, and each fixes a defect rather
+than relocating code — but each lands with its own tests and snapshot updates so a byte change is
+reviewed as a behavior change rather than disappearing inside a large move:
+
+1. **`string()` for Python and Go.** Defining a real escaping policy replaces 47 raw `JSON.stringify`
+ calls and will differ for some inputs (non-ASCII, U+2028/U+2029, Go rune escapes).
+2. **Merging the two TypeScript escapers.** Adopting the stricter policy means `<`/`>` are escaped in
+ places that previously left them literal.
+3. **Unifying the pagination resolvers.** Wherever the two disagree today, one language's output changes.
+
+## Stale documentation to fix alongside
+
+- **ADR-0001** documents `ts.factory` AST codegen. Superseded by the printer ADR.
+- **ARCHITECTURE.md** describes `emitters/ts.ts`, `emitters/package-client.ts`, and a `getWriter`
+ pipeline seam. None exist.
+- **`jsdoc.ts:11`** refers the reader to `ts.ts`'s `jsdoc` helper, which was deleted.
diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md
index fd8b937dba..f8b8ae09e0 100644
--- a/packages/client-generator/eject-assets/AGENTS.md
+++ b/packages/client-generator/eject-assets/AGENTS.md
@@ -15,8 +15,8 @@ client:
/** @type {import('@redocly/client-generator').CustomGenerator} */
export default {
name: 'my-generator',
- run({ model, outputPath, outputMode, emit }) {
- return [{ path: outputPath.replace(/\.ts$/, '.mine.txt'), content: '…' }];
+ run({ model, output, outputMode, emit }) {
+ return [{ path: output.path.replace(/\.ts$/, '.mine.txt'), content: '…' }];
},
// Optional: one idiomatic call snippet per operation for docs (x-codeSamples),
// collected into an overlay file when `client.codeSamples: true` is set.
@@ -26,8 +26,8 @@ export default {
// Optional: the reference page for what `run` emits, written when `client.docs` (or
// --docs) is on. Same `{ path, content }` shape as `run`; `renderReferencePage` gives
// the standard layout and takes `sample` for its snippets. A generator documents itself.
- docs({ model, outputPath, emit }) {
- return [{ path: outputPath.replace(/\.ts$/, '.mine.md'), content: '…' }];
+ docs({ model, output, emit }) {
+ return [{ path: output.path.replace(/\.ts$/, '.mine.md'), content: '…' }];
},
};
```
@@ -45,9 +45,9 @@ export default {
properties: { groupBy: { enum: ['tag', 'path'], default: 'tag' } },
additionalProperties: false,
},
- run({ model, outputPath, options }) {
+ run({ model, output, options }) {
return [
- { path: outputPath.replace(/\.ts$/, '.permissions.md'), content: render(options.groupBy) },
+ { path: output.path.replace(/\.ts$/, '.permissions.md'), content: render(options.groupBy) },
];
},
};
@@ -91,22 +91,28 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`,
## Helpers (import from '@redocly/client-generator')
-| Helper | Use |
-| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
-| `flattenAllOf(schema, model)` | The merged property view of allOf compositions — languages without intersection types render this. |
-| `discriminatorCases(schema, model)` | `{ property, cases }` dispatch table for discriminated unions. |
-| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). |
-| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. |
-| `headerCoerceType(schema, model)` | Response-header coerce hint (`integer`/`number`/`boolean`/`string`) through refs, nullables, and allOf wrappers. |
-| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). |
-| `uniqueIdentifiers(names, { style, reserved, taken })` | The same, made unique among themselves and among names you already took — for a signature that takes one argument per parameter. |
-| `Printer` | Indentation-aware text builder — no manual whitespace bookkeeping. |
-| `docText(description)` | Description as trimmed lines for any comment syntax. |
-| `schemaAtPointer(schema, pointer, model)` | Resolve an RFC 6901 JSON pointer over a schema (through refs and allOf) — e.g. a pagination `items` pointer to its element type. |
-| `paginationRuleFor(op, config)` | The pagination rule that applies to an operation (per-op config > extension > fitting convention), normalized. |
-| `renderReferencePage(model, options)` | The Markdown reference page a generator's `docs` hook returns — your `sample` hook supplies its call snippets. |
-| `NotSupportedError` | Throw it to reject an option the generator can't honor — the CLI prints the message as a user error, not a crash. |
-| `AUTHORING_HELPER_NAMES` | The list of the above (introspection). |
+| Helper | Use |
+| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
+| `flattenAllOf(schema, model)` | The merged property view of allOf compositions — languages without intersection types render this. |
+| `deref(schema, model)` | Follow a `ref` chain to the schema it names (cycle-guarded). |
+| `jsonSuccessSchema(op)` / `sseResponse(op)` | The primary JSON success schema; the `text/event-stream` response when the operation streams. |
+| `isMultipartBody(op)` | Whether the request body is multipart. |
+| `serverUrlParts(server)` | A server-URL template as literal/variable parts, ready for any concatenation syntax. |
+| `securityRequirements(op, model)` | The operation's security as OR-alternatives of AND-sets, denormalized against the declared schemes. |
+| `paginationItemSchema(pageSchema, itemsPointer, model)` | The raw element schema behind a pagination rule's `items` pointer — a `ref` element keeps its name. |
+| `discriminatorCases(schema, model)` | `{ property, cases }` dispatch table for discriminated unions. |
+| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). |
+| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. |
+| `headerCoerceType(schema, model)` | Response-header coerce hint (`integer`/`number`/`boolean`/`string`) through refs, nullables, and allOf wrappers. |
+| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). |
+| `uniqueIdentifiers(names, { style, reserved, taken })` | The same, made unique among themselves and among names you already took — for a signature that takes one argument per parameter. |
+| `Printer` | Indentation-aware text builder — no manual whitespace bookkeeping. |
+| `docText(description)` | Description as trimmed lines for any comment syntax. |
+| `schemaAtPointer(schema, pointer, model)` | Resolve an RFC 6901 JSON pointer over a schema (through refs and allOf) — e.g. a pagination `items` pointer to its element type. |
+| `paginationRuleFor(op, config)` | The pagination rule that applies to an operation (per-op config > extension > fitting convention), normalized. |
+| `renderReferencePage(model, options)` | The Markdown reference page a generator's `docs` hook returns — your `sample` hook supplies its call snippets. |
+| `NotSupportedError` | Throw it to reject an option the generator can't honor — the CLI prints the message as a user error, not a crash. |
+| `AUTHORING_HELPER_NAMES` | The list of the above (introspection). |
Worked example: the built-in `python` generator
(`packages/client-generator/src/generators/python/index.ts` in the Redocly CLI repo) is
diff --git a/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md b/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md
index 5aa8ca6dcc..8b19084b4a 100644
--- a/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md
+++ b/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md
@@ -1,13 +1,13 @@
---
name: cli-generator
-description: Design of the ejected Redocly `cli` client generator. Read it, and update it, before changing generators/cli.mjs.
+description: Design of the ejected Redocly `cli` client generator. Read it, and update it, before changing generators/cli/.
---
# The `cli` generator — its skill
-This file is the DESIGN of your ejected `cli` generator (`generators/cli.mjs`):
+This file is the DESIGN of your ejected `cli` generator (`generators/cli/`):
**to change the generator, edit this skill first, then make the code match it** — a diff
-to `generators/cli.mjs` that has no covering sentence here is incomplete.
+to `generators/cli/` that has no covering sentence here is incomplete.
## What it emits
@@ -105,7 +105,7 @@ codes are a contract for scripts, so change them only deliberately.
## The modify loop
1. Edit this skill: state the new behavior or decision.
-2. Make `generators/cli.mjs` match it.
+2. Make `generators/cli/` match it.
3. Run `redocly generate-client` and inspect the `git diff` of the generated output —
generated files are never hand-edited.
diff --git a/packages/client-generator/eject-assets/skills/client-generators/SKILL.md b/packages/client-generator/eject-assets/skills/client-generators/SKILL.md
index 3a0250145a..eec5673b4f 100644
--- a/packages/client-generator/eject-assets/skills/client-generators/SKILL.md
+++ b/packages/client-generator/eject-assets/skills/client-generators/SKILL.md
@@ -20,8 +20,8 @@ client:
/** @type {import('@redocly/client-generator').CustomGenerator} */
export default {
name: 'my-generator',
- run({ model, outputPath, outputMode, emit }) {
- return [{ path: outputPath.replace(/\.ts$/, '.mine.txt'), content: '…' }];
+ run({ model, output, outputMode, emit }) {
+ return [{ path: output.path.replace(/\.ts$/, '.mine.txt'), content: '…' }];
},
// Optional: one idiomatic call snippet per operation for docs (x-codeSamples),
// collected into an overlay file when `client.codeSamples: true` is set.
@@ -31,8 +31,8 @@ export default {
// Optional: the reference page for what `run` emits, written when `client.docs` (or
// --docs) is on. Same `{ path, content }` shape as `run`; `renderReferencePage` gives
// the standard layout and takes `sample` for its snippets. A generator documents itself.
- docs({ model, outputPath, emit }) {
- return [{ path: outputPath.replace(/\.ts$/, '.mine.md'), content: '…' }];
+ docs({ model, output, emit }) {
+ return [{ path: output.path.replace(/\.ts$/, '.mine.md'), content: '…' }];
},
};
```
@@ -50,9 +50,9 @@ export default {
properties: { groupBy: { enum: ['tag', 'path'], default: 'tag' } },
additionalProperties: false,
},
- run({ model, outputPath, options }) {
+ run({ model, output, options }) {
return [
- { path: outputPath.replace(/\.ts$/, '.permissions.md'), content: render(options.groupBy) },
+ { path: output.path.replace(/\.ts$/, '.permissions.md'), content: render(options.groupBy) },
];
},
};
@@ -96,22 +96,28 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`,
## Helpers (import from '@redocly/client-generator')
-| Helper | Use |
-| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
-| `flattenAllOf(schema, model)` | The merged property view of allOf compositions — languages without intersection types render this. |
-| `discriminatorCases(schema, model)` | `{ property, cases }` dispatch table for discriminated unions. |
-| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). |
-| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. |
-| `headerCoerceType(schema, model)` | Response-header coerce hint (`integer`/`number`/`boolean`/`string`) through refs, nullables, and allOf wrappers. |
-| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). |
-| `uniqueIdentifiers(names, { style, reserved, taken })` | The same, made unique among themselves and among names you already took — for a signature that takes one argument per parameter. |
-| `Printer` | Indentation-aware text builder — no manual whitespace bookkeeping. |
-| `docText(description)` | Description as trimmed lines for any comment syntax. |
-| `schemaAtPointer(schema, pointer, model)` | Resolve an RFC 6901 JSON pointer over a schema (through refs and allOf) — e.g. a pagination `items` pointer to its element type. |
-| `paginationRuleFor(op, config)` | The pagination rule that applies to an operation (per-op config > extension > fitting convention), normalized. |
-| `renderReferencePage(model, options)` | The Markdown reference page a generator's `docs` hook returns — your `sample` hook supplies its call snippets. |
-| `NotSupportedError` | Throw it to reject an option the generator can't honor — the CLI prints the message as a user error, not a crash. |
-| `AUTHORING_HELPER_NAMES` | The list of the above (introspection). |
+| Helper | Use |
+| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
+| `flattenAllOf(schema, model)` | The merged property view of allOf compositions — languages without intersection types render this. |
+| `deref(schema, model)` | Follow a `ref` chain to the schema it names (cycle-guarded). |
+| `jsonSuccessSchema(op)` / `sseResponse(op)` | The primary JSON success schema; the `text/event-stream` response when the operation streams. |
+| `isMultipartBody(op)` | Whether the request body is multipart. |
+| `serverUrlParts(server)` | A server-URL template as literal/variable parts, ready for any concatenation syntax. |
+| `securityRequirements(op, model)` | The operation's security as OR-alternatives of AND-sets, denormalized against the declared schemes. |
+| `paginationItemSchema(pageSchema, itemsPointer, model)` | The raw element schema behind a pagination rule's `items` pointer — a `ref` element keeps its name. |
+| `discriminatorCases(schema, model)` | `{ property, cases }` dispatch table for discriminated unions. |
+| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). |
+| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. |
+| `headerCoerceType(schema, model)` | Response-header coerce hint (`integer`/`number`/`boolean`/`string`) through refs, nullables, and allOf wrappers. |
+| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). |
+| `uniqueIdentifiers(names, { style, reserved, taken })` | The same, made unique among themselves and among names you already took — for a signature that takes one argument per parameter. |
+| `Printer` | Indentation-aware text builder — no manual whitespace bookkeeping. |
+| `docText(description)` | Description as trimmed lines for any comment syntax. |
+| `schemaAtPointer(schema, pointer, model)` | Resolve an RFC 6901 JSON pointer over a schema (through refs and allOf) — e.g. a pagination `items` pointer to its element type. |
+| `paginationRuleFor(op, config)` | The pagination rule that applies to an operation (per-op config > extension > fitting convention), normalized. |
+| `renderReferencePage(model, options)` | The Markdown reference page a generator's `docs` hook returns — your `sample` hook supplies its call snippets. |
+| `NotSupportedError` | Throw it to reject an option the generator can't honor — the CLI prints the message as a user error, not a crash. |
+| `AUTHORING_HELPER_NAMES` | The list of the above (introspection). |
Worked example: the built-in `python` generator
(`packages/client-generator/src/generators/python/index.ts` in the Redocly CLI repo) is
diff --git a/packages/client-generator/eject-assets/skills/go-generator/SKILL.md b/packages/client-generator/eject-assets/skills/go-generator/SKILL.md
index d461feb9e9..b46cbd1df9 100644
--- a/packages/client-generator/eject-assets/skills/go-generator/SKILL.md
+++ b/packages/client-generator/eject-assets/skills/go-generator/SKILL.md
@@ -1,13 +1,13 @@
---
name: go-generator
-description: Design of the ejected Redocly `go` client generator. Read it, and update it, before changing generators/go.mjs.
+description: Design of the ejected Redocly `go` client generator. Read it, and update it, before changing generators/go/.
---
# The `go` generator — its skill
-This file is the DESIGN of your ejected `go` generator (`generators/go.mjs`):
+This file is the DESIGN of your ejected `go` generator (`generators/go/`):
**to change the generator, edit this skill first, then make the code match it** — a diff
-to `generators/go.mjs` that has no covering sentence here is incomplete.
+to `generators/go/` that has no covering sentence here is incomplete.
## What it emits
@@ -72,8 +72,10 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies.
inside a doc comment is `//` — never `// ` with a trailing space.
A change here is verified by the `gofmt -l` bar in the unit suite, at cafe AND
large-description scale.
-- The runtime is hand-written in `runtime/go/runtime.go` (gofmt-clean, `go vet`-clean)
+- The runtime is hand-written in `runtime/runtime.go` in this folder (gofmt-clean, `go vet`-clean)
and embedded at prepare time.
+ Under `--runtime module` it is written as a same-package `runtime.go` beside the client,
+ whose import block then lists only the packages its own body uses.
- Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise.
- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes
@@ -87,7 +89,7 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies.
## The modify loop
1. Edit this skill: state the new behavior or decision.
-2. Make `generators/go.mjs` match it.
+2. Make `generators/go/` match it.
3. Run `redocly generate-client` and inspect the `git diff` of the generated output —
generated files are never hand-edited.
diff --git a/packages/client-generator/eject-assets/skills/mock-generator/SKILL.md b/packages/client-generator/eject-assets/skills/mock-generator/SKILL.md
index b46113901b..8b9f16d0fb 100644
--- a/packages/client-generator/eject-assets/skills/mock-generator/SKILL.md
+++ b/packages/client-generator/eject-assets/skills/mock-generator/SKILL.md
@@ -1,13 +1,13 @@
---
name: mock-generator
-description: Design of the ejected Redocly `mock` client generator. Read it, and update it, before changing generators/mock.mjs.
+description: Design of the ejected Redocly `mock` client generator. Read it, and update it, before changing generators/mock/.
---
# The `mock` generator — its skill
-This file is the DESIGN of your ejected `mock` generator (`generators/mock.mjs`):
+This file is the DESIGN of your ejected `mock` generator (`generators/mock/`):
**to change the generator, edit this skill first, then make the code match it** — a diff
-to `generators/mock.mjs` that has no covering sentence here is incomplete.
+to `generators/mock/` that has no covering sentence here is incomplete.
## What it emits
@@ -38,7 +38,7 @@ Change the data strategy, the handler shape, or the factory surface, and regener
## The modify loop
1. Edit this skill: state the new behavior or decision.
-2. Make `generators/mock.mjs` match it.
+2. Make `generators/mock/` match it.
3. Run `redocly generate-client` and inspect the `git diff` of the generated output —
generated files are never hand-edited.
diff --git a/packages/client-generator/eject-assets/skills/php-generator/SKILL.md b/packages/client-generator/eject-assets/skills/php-generator/SKILL.md
index acfed7323e..a280503ed8 100644
--- a/packages/client-generator/eject-assets/skills/php-generator/SKILL.md
+++ b/packages/client-generator/eject-assets/skills/php-generator/SKILL.md
@@ -1,13 +1,13 @@
---
name: php-generator
-description: Design of the ejected Redocly `php` client generator. Read it, and update it, before changing generators/php.mjs.
+description: Design of the ejected Redocly `php` client generator. Read it, and update it, before changing generators/php/.
---
# The `php` generator — its skill
-This file is the DESIGN of your ejected `php` generator (`generators/php.mjs`):
+This file is the DESIGN of your ejected `php` generator (`generators/php/`):
**to change the generator, edit this skill first, then make the code match it** — a diff
-to `generators/php.mjs` that has no covering sentence here is incomplete.
+to `generators/php/` that has no covering sentence here is incomplete.
## What it emits
@@ -74,8 +74,10 @@ $idempotencyKey` on mutating methods.
- **Parity surface:** auth, retries with `Retry-After` + jittered backoff, per-attempt
curl timeouts, middleware callables, pagination (`Pages()` / `Items()` as
`\Generator`s), SSE (`iterSse` over a curl_multi pump), multipart.
-- The runtime is hand-written in `runtime/php/runtime.php` (`php -l`-clean) and embedded
+- The runtime is hand-written in `runtime/runtime.php` in this folder (`php -l`-clean) and embedded
at prepare time. `curl_close` is never called (deprecated since PHP 8.5, no-op since 8.0).
+ Under `--runtime module` it is written as a `runtime.php` the client `require_once`s,
+ with its namespace rewritten to the client's so one namespace spans both files.
- Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise.
## Migrating from a service-based SDK
@@ -103,7 +105,7 @@ $idempotencyKey` on mutating methods.
## The modify loop
1. Edit this skill: state the new behavior or decision.
-2. Make `generators/php.mjs` match it.
+2. Make `generators/php/` match it.
3. Run `redocly generate-client` and inspect the `git diff` of the generated output —
generated files are never hand-edited.
diff --git a/packages/client-generator/eject-assets/skills/python-generator/SKILL.md b/packages/client-generator/eject-assets/skills/python-generator/SKILL.md
index 29204fe6d9..79ea675bf2 100644
--- a/packages/client-generator/eject-assets/skills/python-generator/SKILL.md
+++ b/packages/client-generator/eject-assets/skills/python-generator/SKILL.md
@@ -1,13 +1,13 @@
---
name: python-generator
-description: Design of the ejected Redocly `python` client generator. Read it, and update it, before changing generators/python.mjs.
+description: Design of the ejected Redocly `python` client generator. Read it, and update it, before changing generators/python/.
---
# The `python` generator — its skill
-This file is the DESIGN of your ejected `python` generator (`generators/python.mjs`):
+This file is the DESIGN of your ejected `python` generator (`generators/python/`):
**to change the generator, edit this skill first, then make the code match it** — a diff
-to `generators/python.mjs` that has no covering sentence here is incomplete.
+to `generators/python/` that has no covering sentence here is incomplete.
## What it emits
@@ -85,8 +85,10 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a
- **Parity surface:** auth (bearer/basic/apiKey), retries with `Retry-After` + jittered
backoff, timeouts, idempotency keys, middleware, pagination (`_pages()` /
`_items()` + `aiter` mirrors), SSE (`iter_sse`/`aiter_sse`), multipart.
-- The runtime is hand-written in `runtime/python/*.py` and embedded as strings at prepare
+- The runtime is hand-written in `runtime/*.py` in this folder and embedded as strings at prepare
time — generator code never builds runtime logic from templates.
+ Under `--runtime module` the same sources are written as sibling `_*.py` files instead
+ (package-relative imports become sibling imports; the client star-imports each module).
- Authored ONLY with the neutral toolkit (`Printer`, naming, schema, pagination helpers) —
the dogfooding guard fails otherwise.
@@ -101,7 +103,7 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a
## The modify loop
1. Edit this skill: state the new behavior or decision.
-2. Make `generators/python.mjs` match it.
+2. Make `generators/python/` match it.
3. Run `redocly generate-client` and inspect the `git diff` of the generated output —
generated files are never hand-edited.
diff --git a/packages/client-generator/eject-assets/skills/swr-generator/SKILL.md b/packages/client-generator/eject-assets/skills/swr-generator/SKILL.md
index e0fc15fef2..a0f53b4a8b 100644
--- a/packages/client-generator/eject-assets/skills/swr-generator/SKILL.md
+++ b/packages/client-generator/eject-assets/skills/swr-generator/SKILL.md
@@ -1,13 +1,13 @@
---
name: swr-generator
-description: Design of the ejected Redocly `swr` client generator. Read it, and update it, before changing generators/swr.mjs.
+description: Design of the ejected Redocly `swr` client generator. Read it, and update it, before changing generators/swr/.
---
# The `swr` generator — its skill
-This file is the DESIGN of your ejected `swr` generator (`generators/swr.mjs`):
+This file is the DESIGN of your ejected `swr` generator (`generators/swr/`):
**to change the generator, edit this skill first, then make the code match it** — a diff
-to `generators/swr.mjs` that has no covering sentence here is incomplete.
+to `generators/swr/` that has no covering sentence here is incomplete.
## What it emits
@@ -37,7 +37,7 @@ Change the hook shape or the key strategy, and regenerate.
## The modify loop
1. Edit this skill: state the new behavior or decision.
-2. Make `generators/swr.mjs` match it.
+2. Make `generators/swr/` match it.
3. Run `redocly generate-client` and inspect the `git diff` of the generated output —
generated files are never hand-edited.
diff --git a/packages/client-generator/eject-assets/skills/tanstack-query-generator/SKILL.md b/packages/client-generator/eject-assets/skills/tanstack-query-generator/SKILL.md
index 96f9cd4e80..87bf048a07 100644
--- a/packages/client-generator/eject-assets/skills/tanstack-query-generator/SKILL.md
+++ b/packages/client-generator/eject-assets/skills/tanstack-query-generator/SKILL.md
@@ -1,13 +1,13 @@
---
name: tanstack-query-generator
-description: Design of the ejected Redocly `tanstack-query` client generator. Read it, and update it, before changing generators/tanstack-query.mjs.
+description: Design of the ejected Redocly `tanstack-query` client generator. Read it, and update it, before changing generators/tanstack-query/.
---
# The `tanstack-query` generator — its skill
-This file is the DESIGN of your ejected `tanstack-query` generator (`generators/tanstack-query.mjs`):
+This file is the DESIGN of your ejected `tanstack-query` generator (`generators/tanstack-query/`):
**to change the generator, edit this skill first, then make the code match it** — a diff
-to `generators/tanstack-query.mjs` that has no covering sentence here is incomplete.
+to `generators/tanstack-query/` that has no covering sentence here is incomplete.
## What it emits
@@ -41,7 +41,7 @@ export (`tanstackQueryGenerator('react')`), so switch it to `'vue'`, `'svelte'`,
## The modify loop
1. Edit this skill: state the new behavior or decision.
-2. Make `generators/tanstack-query.mjs` match it.
+2. Make `generators/tanstack-query/` match it.
3. Run `redocly generate-client` and inspect the `git diff` of the generated output —
generated files are never hand-edited.
diff --git a/packages/client-generator/eject-assets/skills/transformers-generator/SKILL.md b/packages/client-generator/eject-assets/skills/transformers-generator/SKILL.md
index 62c09908c7..15b0c137e0 100644
--- a/packages/client-generator/eject-assets/skills/transformers-generator/SKILL.md
+++ b/packages/client-generator/eject-assets/skills/transformers-generator/SKILL.md
@@ -1,13 +1,13 @@
---
name: transformers-generator
-description: Design of the ejected Redocly `transformers` client generator. Read it, and update it, before changing generators/transformers.mjs.
+description: Design of the ejected Redocly `transformers` client generator. Read it, and update it, before changing generators/transformers/.
---
# The `transformers` generator — its skill
-This file is the DESIGN of your ejected `transformers` generator (`generators/transformers.mjs`):
+This file is the DESIGN of your ejected `transformers` generator (`generators/transformers/`):
**to change the generator, edit this skill first, then make the code match it** — a diff
-to `generators/transformers.mjs` that has no covering sentence here is incomplete.
+to `generators/transformers/` that has no covering sentence here is incomplete.
## What it emits
@@ -36,7 +36,7 @@ uses — one small `.mjs` you own, importing `@redocly/client-generator` and
## The modify loop
1. Edit this skill: state the new behavior or decision.
-2. Make `generators/transformers.mjs` match it.
+2. Make `generators/transformers/` match it.
3. Run `redocly generate-client` and inspect the `git diff` of the generated output —
generated files are never hand-edited.
diff --git a/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md b/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md
index bede4fa750..61fc349466 100644
--- a/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md
+++ b/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md
@@ -1,13 +1,13 @@
---
name: typescript-generator
-description: Design of the ejected Redocly `typescript` client generator. Read it, and update it, before changing generators/typescript.mjs.
+description: Design of the ejected Redocly `typescript` client generator. Read it, and update it, before changing generators/typescript/.
---
# The `typescript` generator — its skill
-This file is the DESIGN of your ejected `typescript` generator (`generators/typescript.mjs`):
+This file is the DESIGN of your ejected `typescript` generator (`generators/typescript/`):
**to change the generator, edit this skill first, then make the code match it** — a diff
-to `generators/typescript.mjs` that has no covering sentence here is incomplete.
+to `generators/typescript/` that has no covering sentence here is incomplete.
## What it emits
@@ -73,7 +73,7 @@ generation time.
## The modify loop
1. Edit this skill: state the new behavior or decision.
-2. Make `generators/typescript.mjs` match it.
+2. Make `generators/typescript/` match it.
3. Run `redocly generate-client` and inspect the `git diff` of the generated output —
generated files are never hand-edited.
diff --git a/packages/client-generator/eject-assets/skills/zod-generator/SKILL.md b/packages/client-generator/eject-assets/skills/zod-generator/SKILL.md
index 4e6ea0e987..d14ccd074f 100644
--- a/packages/client-generator/eject-assets/skills/zod-generator/SKILL.md
+++ b/packages/client-generator/eject-assets/skills/zod-generator/SKILL.md
@@ -1,13 +1,13 @@
---
name: zod-generator
-description: Design of the ejected Redocly `zod` client generator. Read it, and update it, before changing generators/zod.mjs.
+description: Design of the ejected Redocly `zod` client generator. Read it, and update it, before changing generators/zod/.
---
# The `zod` generator — its skill
-This file is the DESIGN of your ejected `zod` generator (`generators/zod.mjs`):
+This file is the DESIGN of your ejected `zod` generator (`generators/zod/`):
**to change the generator, edit this skill first, then make the code match it** — a diff
-to `generators/zod.mjs` that has no covering sentence here is incomplete.
+to `generators/zod/` that has no covering sentence here is incomplete.
## What it emits
@@ -42,7 +42,7 @@ Change the schema shapes, the naming, or what gets a schema at all, and regenera
## The modify loop
1. Edit this skill: state the new behavior or decision.
-2. Make `generators/zod.mjs` match it.
+2. Make `generators/zod/` match it.
3. Run `redocly generate-client` and inspect the `git diff` of the generated output —
generated files are never hand-edited.
diff --git a/packages/client-generator/package.json b/packages/client-generator/package.json
index 8ab58e2f69..cd588040a8 100644
--- a/packages/client-generator/package.json
+++ b/packages/client-generator/package.json
@@ -1,6 +1,6 @@
{
"name": "@redocly/client-generator",
- "version": "0.3.7",
+ "version": "0.3.8",
"description": "Generate typed, zero-dependency TypeScript clients (fetch, auth, retries, middleware, SSE) from OpenAPI descriptions.",
"type": "module",
"types": "lib/index.d.ts",
@@ -16,6 +16,36 @@
"import": "./lib/generate.js",
"default": "./lib/generate.js"
},
+ "./printers/python": {
+ "types": "./lib/printers/python.d.ts",
+ "import": "./lib/printers/python.js",
+ "default": "./lib/printers/python.js"
+ },
+ "./printers/go": {
+ "types": "./lib/printers/go.d.ts",
+ "import": "./lib/printers/go.js",
+ "default": "./lib/printers/go.js"
+ },
+ "./printers/php": {
+ "types": "./lib/printers/php.d.ts",
+ "import": "./lib/printers/php.js",
+ "default": "./lib/printers/php.js"
+ },
+ "./printers/typescript": {
+ "types": "./lib/printers/typescript.d.ts",
+ "import": "./lib/printers/typescript.js",
+ "default": "./lib/printers/typescript.js"
+ },
+ "./printers": {
+ "types": "./lib/printers/index.d.ts",
+ "import": "./lib/printers/index.js",
+ "default": "./lib/printers/index.js"
+ },
+ "./contracts/typescript": {
+ "types": "./lib/contracts/typescript.d.ts",
+ "import": "./lib/contracts/typescript.js",
+ "default": "./lib/contracts/typescript.js"
+ },
"./runtime-sources": {
"types": "./lib/runtime-sources.d.ts",
"import": "./lib/runtime-sources.js",
@@ -55,7 +85,7 @@
"Roman Marshevskyi (https://redocly.com/)"
],
"dependencies": {
- "@redocly/openapi-core": "2.46.2"
+ "@redocly/openapi-core": "2.47.0"
},
"peerDependencies": {
"typescript": ">=5.5.0"
diff --git a/packages/client-generator/scripts/ejected-skill.mjs b/packages/client-generator/scripts/ejected-skill.mjs
index 070e94842a..4b9e0f4261 100644
--- a/packages/client-generator/scripts/ejected-skill.mjs
+++ b/packages/client-generator/scripts/ejected-skill.mjs
@@ -2,15 +2,16 @@
// into the user's `.claude/skills/`. The source skill speaks to development inside this repo — its intro and modify
// loop reference index.ts, the prepare script, and our vitest suites, none of which
// exist in a user's repo. The ejected copy keeps the design sections verbatim but
-// rewrites those two parts for the user's world: their file is generators/.mjs
-// and their loop is edit → regenerate → diff. The design bullets in between ship
+// rewrites those two parts for the user's world: their copy is the generator's source
+// folder at generators//, and their loop is edit → regenerate → diff. The design bullets in between ship
// unchanged, and both anchors are structural (the first `## ` heading and the final
// `## The modify loop` section), so skills can grow without touching this transform.
export function ejectedSkill(source, name) {
+ const copy = `generators/${name}/`;
const frontmatter = [
'---',
`name: ${name}-generator`,
- `description: Design of the ejected Redocly \`${name}\` client generator. Read it, and update it, before changing generators/${name}.mjs.`,
+ `description: Design of the ejected Redocly \`${name}\` client generator. Read it, and update it, before changing ${copy}.`,
'---',
'',
].join('\n');
@@ -21,15 +22,15 @@ export function ejectedSkill(source, name) {
throw new Error(`The ${name} skill lost its title/intro/modify-loop structure.`);
}
const intro = [
- `This file is the DESIGN of your ejected \`${name}\` generator (\`generators/${name}.mjs\`):`,
+ `This file is the DESIGN of your ejected \`${name}\` generator (\`${copy}\`):`,
'**to change the generator, edit this skill first, then make the code match it** — a diff',
- `to \`generators/${name}.mjs\` that has no covering sentence here is incomplete.`,
+ `to \`${copy}\` that has no covering sentence here is incomplete.`,
].join('\n');
const modifyLoop = [
'## The modify loop',
'',
'1. Edit this skill: state the new behavior or decision.',
- `2. Make \`generators/${name}.mjs\` match it.`,
+ `2. Make \`${copy}\` match it.`,
'3. Run `redocly generate-client` and inspect the `git diff` of the generated output —',
' generated files are never hand-edited.',
'',
diff --git a/packages/client-generator/scripts/generate-eject-assets.mjs b/packages/client-generator/scripts/generate-eject-assets.mjs
index 2c4ef04b30..a7d5c7c035 100644
--- a/packages/client-generator/scripts/generate-eject-assets.mjs
+++ b/packages/client-generator/scripts/generate-eject-assets.mjs
@@ -1,30 +1,21 @@
import { build } from 'esbuild';
-import { spawnSync } from 'node:child_process';
-import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import ts from 'typescript';
import { ejectedSkill } from './ejected-skill.mjs';
-// Build the ejectable generator assets — one `.mjs` per built-in generator, which
-// `redocly eject-generator ` copies into the user's repo verbatim. Two shapes,
-// because the generators have two shapes:
-//
-// - A language generator is ONE self-contained file, so it ships as its own source,
-// type-stripped with comments preserved and its imports rewritten to the public
-// entries. The user reads their own generator, exactly as we wrote it.
-// - A TypeScript generator is a thin entry over shared emitters, so it ships BUNDLED
-// with the emitters it uses (esbuild, unminified, one module comment per source file).
-// `@redocly/client-generator` and `@redocly/openapi-core` stay external — those are
-// the two packages an ejected generator imports.
-//
-// Both get a provenance header and the `defineGenerator`-shaped default export the
-// resolver loads.
+// Build the ejectable generator assets: one source FOLDER per built-in generator,
+// which `redocly eject-generator ` copies into the user's repo verbatim. Each
+// stage file ships as the TypeScript we wrote — imports already pointing at the public
+// package entries — with a provenance header per file (`--update` merges per file) and
+// the resolver's default export appended to `index.ts`.
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const { version } = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8'));
const outDir = join(pkgRoot, 'eject-assets', 'generators');
const skillsDir = join(pkgRoot, 'eject-assets', 'skills');
+rmSync(outDir, { recursive: true, force: true });
mkdirSync(outDir, { recursive: true });
// The shared authoring skill ships as a skill too, so an agent in the user's repo loads
@@ -128,15 +119,6 @@ function defaultExport(name, { run, sample, options, docs }) {
return `\nexport default {\n${fields.join('\n')}\n};\n`;
}
-/** Fail the build loudly — a broken asset would only surface in a user's repo. */
-function checkSyntax(outFile, name) {
- const check = spawnSync(process.execPath, ['--check', outFile], { encoding: 'utf-8' });
- if (check.status !== 0) {
- process.stderr.write(`eject asset ${name}.mjs failed node --check:\n${check.stderr}`);
- process.exit(1);
- }
-}
-
/** The generator's design, rewritten for the user's repo and shipped as an agent skill. */
function writeSkill(name) {
const skill = readFileSync(join(pkgRoot, 'src', 'generators', name, 'AGENTS.md'), 'utf-8');
@@ -144,97 +126,51 @@ function writeSkill(name) {
writeFileSync(join(skillsDir, `${name}-generator`, 'SKILL.md'), ejectedSkill(skill, name));
}
-const LANGUAGE = [
+/**
+ * Every built-in, with the expression that produces each one's `run`. The
+ * tanstack-query variants share one folder: the framework is a single argument in the
+ * ejected entry, so the copy is the place to change it rather than four near-identical
+ * folders.
+ */
+const GENERATORS = [
{ name: 'python', run: 'pythonGenerator', sample: 'pythonSample', docs: 'pythonDocs' },
{ name: 'go', run: 'goGenerator', sample: 'goSample', docs: 'goDocs' },
{ name: 'php', run: 'phpGenerator', sample: 'phpSample', docs: 'phpDocs' },
-];
-
-/**
- * The TypeScript generators, with the expression that produces each one's `run`. The
- * tanstack-query variants share this bundle: the framework is one argument, so the
- * ejected copy is the place to change it rather than four near-identical files.
- */
-const TYPESCRIPT = [
{
name: 'typescript',
- imports: ['typescriptGenerator', 'typescriptSample', 'typescriptDocs'],
run: 'typescriptGenerator',
sample: 'typescriptSample',
docs: 'typescriptDocs',
},
- { name: 'zod', imports: ['zodGenerator'], run: 'zodGenerator' },
- { name: 'mock', imports: ['mockGenerator'], run: 'mockGenerator' },
- { name: 'swr', imports: ['swrGenerator'], run: 'swrGenerator' },
- { name: 'transformers', imports: ['transformersGenerator'], run: 'transformersGenerator' },
- {
- name: 'cli',
- imports: ['cliGenerator', 'cliSample', 'cliDocs'],
- run: 'cliGenerator',
- sample: 'cliSample',
- docs: 'cliDocs',
- },
- {
- name: 'tanstack-query',
- imports: ['tanstackQueryGenerator'],
- run: "tanstackQueryGenerator('react')",
- },
+ { name: 'zod', run: 'zodGenerator' },
+ { name: 'mock', run: 'mockGenerator' },
+ { name: 'swr', run: 'swrGenerator' },
+ { name: 'transformers', run: 'transformersGenerator' },
+ { name: 'cli', run: 'cliGenerator', sample: 'cliSample', docs: 'cliDocs' },
+ { name: 'tanstack-query', run: "tanstackQueryGenerator('react')" },
];
-for (const { name, imports, run, sample, options, docs } of TYPESCRIPT) {
- // Bundling starts from a generated entry so the default export survives esbuild's
- // renaming: appending it to the bundle would reference a symbol esbuild may have
- // renamed, while an entry module's own export is resolved before that happens.
- const entry = join(pkgRoot, 'eject-assets', `.entry-${name}.mjs`);
- writeFileSync(
- entry,
- `import { ${imports.join(', ')} } from ${JSON.stringify(
- join(pkgRoot, 'src', 'generators', name, 'index.ts')
- )};\n` + defaultExport(name, { run, sample, options, docs })
- );
- const outFile = join(outDir, `${name}.mjs`);
- try {
- await build({
- entryPoints: [entry],
- outfile: outFile,
- bundle: true,
- format: 'esm',
- platform: 'node',
- target: 'node20',
- keepNames: true,
- // Readable output: a user owns this file, so no minification and one comment
- // per source module.
- minify: false,
- external: ['@redocly/client-generator', '@redocly/openapi-core'],
- banner: { js: provenanceHeader(name) },
- logLevel: 'warning',
- });
- } finally {
- rmSync(entry, { force: true });
+for (const { name, run, sample, docs } of GENERATORS) {
+ const sourceDir = join(pkgRoot, 'src', 'generators', name);
+ const assetDir = join(outDir, name);
+ mkdirSync(assetDir, { recursive: true });
+ for (const file of readdirSync(sourceDir).filter((entry) => entry.endsWith('.ts'))) {
+ // The source is the asset: it already imports the public package entries and its
+ // sibling stages by `.ts` extension, so the copy runs under Node's type stripping.
+ // Every file carries the provenance header — `--update` merges per file and reads
+ // the version from the file it is merging.
+ const source = readFileSync(join(sourceDir, file), 'utf-8');
+ const content =
+ provenanceHeader(name) +
+ source +
+ (file === 'index.ts' ? defaultExport(name, { run, sample, docs }) : '');
+ const checked = ts.transpileModule(content, { reportDiagnostics: true });
+ if (checked.diagnostics !== undefined && checked.diagnostics.length > 0) {
+ const message = ts.flattenDiagnosticMessageText(checked.diagnostics[0].messageText, '\n');
+ process.stderr.write(`eject asset ${name}/${file} does not parse: ${message}\n`);
+ process.exit(1);
+ }
+ writeFileSync(join(assetDir, file), content);
}
- checkSyntax(outFile, name);
- writeSkill(name);
-}
-
-for (const { name, run, sample, docs } of LANGUAGE) {
- const source = readFileSync(join(pkgRoot, 'src', 'generators', name, 'index.ts'), 'utf-8')
- .replaceAll("'../../authoring/index.js'", "'@redocly/client-generator'")
- .replaceAll(
- `'../../emitters/${name}-runtime-sources.js'`,
- "'@redocly/client-generator/runtime-sources'"
- );
- const stripped = ts.transpileModule(source, {
- compilerOptions: {
- target: ts.ScriptTarget.ESNext,
- module: ts.ModuleKind.ESNext,
- removeComments: false,
- },
- }).outputText;
- const outFile = join(outDir, `${name}.mjs`);
- writeFileSync(
- outFile,
- provenanceHeader(name) + stripped + defaultExport(name, { run, sample, docs })
- );
- checkSyntax(outFile, name);
writeSkill(name);
}
diff --git a/packages/client-generator/scripts/generate-runtime-sources.mjs b/packages/client-generator/scripts/generate-runtime-sources.mjs
index 7dba49252a..c8320bdc3e 100644
--- a/packages/client-generator/scripts/generate-runtime-sources.mjs
+++ b/packages/client-generator/scripts/generate-runtime-sources.mjs
@@ -3,10 +3,17 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
-// Snapshot src/runtime/*.ts source text into a tracked TS module so the inline assembler
-// can embed the real runtime (a readFileSync asset would not survive the CLI's esbuild
-// bundling). Order is the assembler's fixed dependency order; the barrel (index.ts) is
-// not embedded — the assembler emits its own local createClient wiring.
+// Snapshot the runtime sources (src/generators/typescript/runtime/*.ts and the cli
+// engine at src/generators/cli/runtime/cli.ts) into a tracked TS module so the inline
+// assembler can embed the real runtime (a readFileSync asset would not survive the CLI's
+// esbuild bundling). Order is the assembler's fixed dependency order; the barrel
+// (index.ts) is not embedded — the assembler emits its own local createClient wiring.
+//
+// The contract types the runtime imports from the package level (ADR-0022: the setup
+// contract in src/runtime-contract.ts, `PaginationSpec` beside its resolver in
+// src/pagination.ts) are spliced back into the embedded `types.ts` here, replacing the
+// re-export statements — the embedded module stays self-contained with one definition
+// in the source tree.
const MODULES = [
'types',
'errors',
@@ -24,8 +31,92 @@ const MODULES = [
];
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
-const runtimeDir = join(pkgRoot, 'src', 'runtime');
-const outFile = join(pkgRoot, 'src', 'emitters', 'runtime-sources.ts');
+const runtimeDir = join(pkgRoot, 'src', 'generators', 'typescript', 'runtime');
+const outFile = join(pkgRoot, 'src', 'runtime-sources', 'typescript.ts');
+
+// The package-level modules whose type declarations the embed splices back in, keyed by
+// the specifier the runtime imports them with.
+const CONTRACT_MODULES = {
+ '../../../runtime-contract.js': join(pkgRoot, 'src', 'runtime-contract.ts'),
+ '../../../pagination.js': join(pkgRoot, 'src', 'pagination.ts'),
+ '../../../cli-contract.js': join(pkgRoot, 'src', 'cli-contract.ts'),
+};
+
+/** The declaration's start including its own doc comment, excluding detached trivia. */
+function declStartWithDocs(source, declaration) {
+ const ranges = ts.getLeadingCommentRanges(source, declaration.getFullStart()) ?? [];
+ let start = declaration.getStart();
+ for (let index = ranges.length - 1; index >= 0; index--) {
+ if (/\n\s*\n/.test(source.slice(ranges[index].end, start))) break;
+ start = ranges[index].pos;
+ }
+ return start;
+}
+
+/** The named type declarations of a contract module, verbatim and in source order. */
+function contractDeclarationsText(modulePath, names) {
+ const source = readFileSync(modulePath, 'utf-8');
+ const file = ts.createSourceFile('__contract.ts', source, ts.ScriptTarget.Latest, true);
+ const wanted = new Set(names);
+ const parts = [];
+ for (const statement of file.statements) {
+ const named =
+ (ts.isTypeAliasDeclaration(statement) || ts.isFunctionDeclaration(statement)) &&
+ statement.name !== undefined;
+ if (named && wanted.has(statement.name.text)) {
+ parts.push(source.slice(declStartWithDocs(source, statement), statement.end));
+ wanted.delete(statement.name.text);
+ }
+ }
+ if (wanted.size > 0) {
+ throw new Error(`contract splice: ${[...wanted].join(', ')} not found in ${modulePath}`);
+ }
+ return parts.join('\n\n');
+}
+
+/**
+ * Replace the runtime module's contract imports/re-exports with the definitions they
+ * point at, so every downstream use (full source, stripped embed, declared names) sees
+ * one self-contained module.
+ */
+function spliceContracts(source) {
+ const file = ts.createSourceFile('__splice.ts', source, ts.ScriptTarget.Latest, true);
+ const edits = [];
+ for (const statement of file.statements) {
+ if (ts.isImportDeclaration(statement) && CONTRACT_MODULES[statement.moduleSpecifier.text]) {
+ // Delete the import line and its trailing newlines only — the module's header
+ // comment is this statement's leading trivia and must survive.
+ let end = statement.end;
+ while (source[end] === '\n') end++;
+ edits.push({ start: statement.getStart(), end, text: '' });
+ } else if (
+ ts.isExportDeclaration(statement) &&
+ statement.moduleSpecifier !== undefined &&
+ CONTRACT_MODULES[statement.moduleSpecifier.text]
+ ) {
+ const names = statement.exportClause.elements.map((element) => element.name.text);
+ const block = contractDeclarationsText(
+ CONTRACT_MODULES[statement.moduleSpecifier.text],
+ names
+ );
+ edits.push({ start: statement.getFullStart(), end: statement.end, text: `\n\n${block}` });
+ }
+ }
+ let spliced = source;
+ for (const edit of edits.reverse()) {
+ spliced = spliced.slice(0, edit.start) + edit.text + spliced.slice(edit.end);
+ }
+ return spliced;
+}
+
+/** A runtime module's embeddable source: the cli engine lives in the cli generator. */
+function runtimeSource(name) {
+ const path =
+ name === 'cli'
+ ? join(pkgRoot, 'src', 'generators', 'cli', 'runtime', 'cli.ts')
+ : join(runtimeDir, `${name}.ts`);
+ return spliceContracts(readFileSync(path, 'utf-8'));
+}
// Emit the literal exactly as oxfmt (singleQuote: true) would format it, so that
// compile → format is a no-op: prefer single quotes unless that needs more escapes.
@@ -41,7 +132,7 @@ function toStringLiteral(source) {
}
const entries = MODULES.map((name) => {
- const source = readFileSync(join(runtimeDir, `${name}.ts`), 'utf-8');
+ const source = runtimeSource(name);
const line = ` '${name}.ts': ${toStringLiteral(source)},`;
// oxfmt (printWidth: 100) breaks an over-width property onto a continuation line.
return line.length <= 100 ? line : ` '${name}.ts':\n ${toStringLiteral(source)},`;
@@ -53,7 +144,7 @@ const entries = MODULES.map((name) => {
function declaredNames() {
const names = new Set();
for (const name of MODULES) {
- const source = readFileSync(join(runtimeDir, `${name}.ts`), 'utf-8');
+ const source = runtimeSource(name);
const file = ts.createSourceFile(`${name}.ts`, source, ts.ScriptTarget.Latest, false);
for (const statement of file.statements) {
if (
@@ -75,7 +166,7 @@ function declaredNames() {
return [...names].sort();
}
-// The Python runtime (runtime/python/*.py) embeds the same way: hand-authored
+// The Python runtime (src/generators/python/runtime/*.py) embeds the same way: hand-authored
// once, stitched into every generated Python client by the python generator.
const PYTHON_MODULES = [
'_errors',
@@ -87,8 +178,8 @@ const PYTHON_MODULES = [
'_sse',
'_multipart',
];
-const pythonDir = join(pkgRoot, 'runtime', 'python');
-const pythonOut = join(pkgRoot, 'src', 'emitters', 'python-runtime-sources.ts');
+const pythonDir = join(pkgRoot, 'src', 'generators', 'python', 'runtime');
+const pythonOut = join(pkgRoot, 'src', 'runtime-sources', 'python.ts');
const pythonEntries = PYTHON_MODULES.map((name) => {
const source = readFileSync(join(pythonDir, `${name}.py`), 'utf-8');
const line = ` '${name}.py': ${toStringLiteral(source)},`;
@@ -108,8 +199,8 @@ writeFileSync(
);
// The Go runtime embeds the same way (a single stdlib-only module).
-const goDir = join(pkgRoot, 'runtime', 'go');
-const goOut = join(pkgRoot, 'src', 'emitters', 'go-runtime-sources.ts');
+const goDir = join(pkgRoot, 'src', 'generators', 'go', 'runtime');
+const goOut = join(pkgRoot, 'src', 'runtime-sources', 'go.ts');
const goSource = readFileSync(join(goDir, 'runtime.go'), 'utf-8');
writeFileSync(
goOut,
@@ -122,8 +213,8 @@ writeFileSync(
);
// The PHP runtime embeds the same way (a single curl-only module).
-const phpDir = join(pkgRoot, 'runtime', 'php');
-const phpOut = join(pkgRoot, 'src', 'emitters', 'php-runtime-sources.ts');
+const phpDir = join(pkgRoot, 'src', 'generators', 'php', 'runtime');
+const phpOut = join(pkgRoot, 'src', 'runtime-sources', 'php.ts');
const phpSource = readFileSync(join(phpDir, 'runtime.php'), 'utf-8');
writeFileSync(
phpOut,
@@ -135,7 +226,7 @@ writeFileSync(
].join('\n')
);
-// Stripped variants for inline embedding (emitters/inline-runtime.ts): imports dropped,
+// Stripped variants for inline embedding (generators/typescript/inline-runtime.ts): imports dropped,
// `export` removed except on the kept surface — done HERE at prepare time so the embed
// path needs no TypeScript at generate time. Slices are AST-position-driven (no regexes),
// so comments and formatting survive byte-for-byte.
@@ -174,7 +265,7 @@ function stripModule(name, source) {
}
const strippedEntries = MODULES.map((name) => {
- const source = readFileSync(join(runtimeDir, `${name}.ts`), 'utf-8');
+ const source = runtimeSource(name);
const stripped = stripModule(`${name}.ts`, source);
const line = ` '${name}.ts': ${toStringLiteral(stripped)},`;
return line.length <= 100 ? line : ` '${name}.ts':\n ${toStringLiteral(stripped)},`;
diff --git a/packages/client-generator/src/__tests__/entry-weight.test.ts b/packages/client-generator/src/__tests__/entry-weight.test.ts
deleted file mode 100644
index 10e05c3f71..0000000000
--- a/packages/client-generator/src/__tests__/entry-weight.test.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { readFileSync } from 'node:fs';
-import { dirname, join, resolve } from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-// Package-mode clients import the package ROOT at app runtime, and native ESM loads
-// every static import eagerly — so the root entry's static graph must stay free of the
-// generation stack (`typescript`, `@redocly/openapi-core`, Node builtins). It is
-// reached only through the dynamic `import('./generate.js')` inside `generateClient`,
-// which this walk deliberately does not follow.
-const libDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../lib');
-
-const STATIC_IMPORT = /(?:^|\n)(?:import|export)\s[^'"]*?from\s+['"]([^'"]+)['"]/g;
-
-function staticGraph(entry: string): { files: Set; externals: Set } {
- const files = new Set();
- const externals = new Set();
- const queue = [entry];
- while (queue.length > 0) {
- const file = queue.pop()!;
- if (files.has(file)) continue;
- files.add(file);
- const source = readFileSync(file, 'utf-8');
- for (const match of source.matchAll(STATIC_IMPORT)) {
- const specifier = match[1];
- if (specifier.startsWith('.')) queue.push(join(dirname(file), specifier));
- else externals.add(specifier);
- }
- }
- return { files, externals };
-}
-
-describe('package root entry (lib/index.js)', () => {
- it('statically loads only the runtime — no typescript, openapi-core, or Node builtins', () => {
- const { files, externals } = staticGraph(join(libDir, 'index.js'));
- expect([...externals]).toEqual([]);
- const outsideRuntime = [...files].filter(
- (file) => file.includes('/emitters/') || file.includes('/intermediate-representation/')
- );
- 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/);
- });
-});
-
-describe('runtime-sources entry (lib/runtime-sources.js)', () => {
- it('statically loads only the generated source-string modules — ejected generators stay TS-free', () => {
- const { files, externals } = staticGraph(join(libDir, 'runtime-sources.js'));
- expect([...externals]).toEqual([]);
- const outsideSources = [...files].filter(
- (file) => !file.endsWith('runtime-sources.js') && !file.endsWith('-runtime-sources.js')
- );
- expect(outsideSources).toEqual([]);
- });
-});
diff --git a/packages/client-generator/src/emitters/__tests__/fixtures.ts b/packages/client-generator/src/__tests__/fixtures.ts
similarity index 80%
rename from packages/client-generator/src/emitters/__tests__/fixtures.ts
rename to packages/client-generator/src/__tests__/fixtures.ts
index 6e57e09f58..400270a11a 100644
--- a/packages/client-generator/src/emitters/__tests__/fixtures.ts
+++ b/packages/client-generator/src/__tests__/fixtures.ts
@@ -1,3 +1,5 @@
+import { emitClientSingleFile } from '../generators/typescript/client-assembly.js';
+import { sseFromResponses } from '../intermediate-representation/build.js';
import type {
ApiModel,
NamedSchemaModel,
@@ -5,8 +7,7 @@ import type {
ParamModel,
ResponseBodyModel,
SchemaModel,
-} from '../../intermediate-representation/model.js';
-import { emitClientSingleFile } from '../client-assembly.js';
+} from '../intermediate-representation/model.js';
/** A plain `string` scalar — the default schema for params and the most-reused leaf. */
export const SCALAR: SchemaModel = { kind: 'scalar', scalar: 'string' };
@@ -34,7 +35,7 @@ export function namedSchema(
/** A minimal `GET /p` operation; spread `overrides` to add params, a body, responses, etc. */
export function operation(overrides: Partial = {}): OperationModel {
- return {
+ const built: OperationModel = {
name: 'op',
method: 'get',
path: '/p',
@@ -48,6 +49,10 @@ export function operation(overrides: Partial = {}): OperationMod
tags: [],
...overrides,
};
+ // The IR builder stamps `sse` on every real operation; mirror it here so fixtures
+ // carry the same facts the emitters read in production.
+ const sse = overrides.sse ?? sseFromResponses(built.successResponses);
+ return { ...built, ...(sse === undefined ? {} : { sse }) };
}
export function param(
diff --git a/packages/client-generator/src/__tests__/index.test.ts b/packages/client-generator/src/__tests__/index.test.ts
index ef8d047e10..82934d5338 100644
--- a/packages/client-generator/src/__tests__/index.test.ts
+++ b/packages/client-generator/src/__tests__/index.test.ts
@@ -169,16 +169,16 @@ describe('collectGeneratedFiles', () => {
}
});
- it('supports runtime: package with outputMode: split (the shared emitter serves both)', () => {
+ it('supports outputMode: split with no schemas (only the entry file)', () => {
const files = collectGeneratedFiles(model(), {
outputPath: '/out/api.ts',
outputMode: 'split',
- emit: { runtime: 'package' },
+ emit: {},
generators: ['typescript'],
});
// No schemas in the model → only the entry file.
expect(files.map((f) => f.path)).toEqual(['/out/api.ts']);
- expect(files[0].content).toContain("from '@redocly/client-generator'");
+ expect(files[0].content).toContain('// ─── Embedded runtime');
});
});
diff --git a/packages/client-generator/src/emitters/__tests__/pagination.test.ts b/packages/client-generator/src/__tests__/pagination.test.ts
similarity index 99%
rename from packages/client-generator/src/emitters/__tests__/pagination.test.ts
rename to packages/client-generator/src/__tests__/pagination.test.ts
index 8475d40f89..95778e46b3 100644
--- a/packages/client-generator/src/emitters/__tests__/pagination.test.ts
+++ b/packages/client-generator/src/__tests__/pagination.test.ts
@@ -2,7 +2,7 @@ import type {
ApiModel,
OperationModel,
SchemaModel,
-} from '../../intermediate-representation/model.js';
+} from '../intermediate-representation/model.js';
import {
type PaginationRule,
resolveModelPagination,
diff --git a/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts b/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts
index 160eba8de4..25e6b9a5e3 100644
--- a/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts
+++ b/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts
@@ -36,26 +36,15 @@ function staticGraph(entry: string): { files: Set; externals: Set {
- it('statically loads no typescript and only the pure emitter helpers', () => {
+ it('statically loads no typescript and no generator folder', () => {
const { files, externals } = staticGraph(join(libDir, 'pipeline.js'));
expect(externals.has('typescript')).toBe(false);
- const emitterFiles = [...files]
- .filter((file) => /\/emitters\//.test(file))
- .map((file) => file.split('/emitters/')[1])
- .filter((name) => !PURE_EMITTER_HELPERS.has(name));
- expect(emitterFiles).toEqual([]);
+ // Built-ins are reached only through the dynamic imports in generators/meta.js —
+ // a generator folder in the static graph would load every language's emit stack
+ // (and, for the TS family, its printers) on every pipeline start.
+ const generatorFiles = [...files].filter((file) => /\/generators\/[a-z-]+\//.test(file));
+ expect(generatorFiles).toEqual([]);
});
});
diff --git a/packages/client-generator/src/emitters/__tests__/reserved-names.test.ts b/packages/client-generator/src/__tests__/reserved-names.test.ts
similarity index 99%
rename from packages/client-generator/src/emitters/__tests__/reserved-names.test.ts
rename to packages/client-generator/src/__tests__/reserved-names.test.ts
index 6311c7987a..ffbd7f4f56 100644
--- a/packages/client-generator/src/emitters/__tests__/reserved-names.test.ts
+++ b/packages/client-generator/src/__tests__/reserved-names.test.ts
@@ -1,7 +1,7 @@
import ts from 'typescript';
import { reservedModuleNames } from '../reserved-names.js';
-import { RUNTIME_SOURCES } from '../runtime-sources.js';
+import { RUNTIME_SOURCES } from '../runtime-sources/typescript.js';
/**
* Every free identifier of a source — referenced but bound in no enclosing scope, so
diff --git a/packages/client-generator/src/emitters/__tests__/setup-bake.test.ts b/packages/client-generator/src/__tests__/setup-bake.test.ts
similarity index 100%
rename from packages/client-generator/src/emitters/__tests__/setup-bake.test.ts
rename to packages/client-generator/src/__tests__/setup-bake.test.ts
diff --git a/packages/client-generator/src/emitters/__tests__/ts-guard.test.ts b/packages/client-generator/src/__tests__/ts-guard.test.ts
similarity index 100%
rename from packages/client-generator/src/emitters/__tests__/ts-guard.test.ts
rename to packages/client-generator/src/__tests__/ts-guard.test.ts
diff --git a/packages/client-generator/src/authoring/__tests__/operation.test.ts b/packages/client-generator/src/authoring/__tests__/operation.test.ts
new file mode 100644
index 0000000000..298b0cac5f
--- /dev/null
+++ b/packages/client-generator/src/authoring/__tests__/operation.test.ts
@@ -0,0 +1,32 @@
+import type { ServerModel } from '../../intermediate-representation/model.js';
+import { serverUrlParts } from '../operation.js';
+
+describe('serverUrlParts', () => {
+ it('splits a template into literals and declared variables, in order', () => {
+ const server = {
+ url: 'https://{region}.api.example.com/{basePath}',
+ variables: [
+ { name: 'region', default: 'us' },
+ { name: 'basePath', default: 'v1' },
+ ],
+ } as ServerModel;
+ expect(serverUrlParts(server)).toEqual([
+ { kind: 'literal', value: 'https://' },
+ { kind: 'variable', name: 'region' },
+ { kind: 'literal', value: '.api.example.com/' },
+ { kind: 'variable', name: 'basePath' },
+ ]);
+ });
+
+ it('keeps an undeclared placeholder as literal text, and never returns zero parts', () => {
+ const undeclared = {
+ url: 'https://{region}.example.com',
+ variables: [],
+ } as unknown as ServerModel;
+ expect(serverUrlParts(undeclared)).toEqual([
+ { kind: 'literal', value: 'https://{region}.example.com' },
+ ]);
+ const empty = { url: '', variables: [] } as unknown as ServerModel;
+ expect(serverUrlParts(empty)).toEqual([{ kind: 'literal', value: '' }]);
+ });
+});
diff --git a/packages/client-generator/src/authoring/__tests__/reference-page.test.ts b/packages/client-generator/src/authoring/__tests__/reference-page.test.ts
new file mode 100644
index 0000000000..d61f478bc6
--- /dev/null
+++ b/packages/client-generator/src/authoring/__tests__/reference-page.test.ts
@@ -0,0 +1,166 @@
+import { apiModel, operation, param } from '../../__tests__/fixtures.js';
+import { renderReferencePage } from '../reference-page.js';
+
+const LANGUAGE = {
+ name: 'python',
+ label: 'Python',
+ fence: 'python',
+ requires: 'Requires Python >= 3.9 and httpx.',
+};
+
+describe('renderReferencePage', () => {
+ it('renders the whole page: front matter, auth table, tag groups, and per-operation facts', () => {
+ const model = apiModel({
+ services: [
+ {
+ name: 'Orders',
+ operations: [
+ operation({
+ name: 'listOrders',
+ specName: 'listOrders',
+ method: 'get',
+ path: '/orders',
+ summary: 'List | orders.',
+ tags: ['Orders'],
+ queryParams: [
+ {
+ name: 'cursor',
+ in: 'query',
+ required: false,
+ schema: { kind: 'scalar', scalar: 'string' },
+ description: 'Page\ncursor.',
+ },
+ param('limit', 'query', true, { kind: 'scalar', scalar: 'integer' }),
+ ],
+ successResponses: [
+ {
+ status: 200,
+ contentType: 'application/json',
+ schema: { kind: 'ref', name: 'OrderPage' },
+ },
+ ],
+ }),
+ operation({
+ name: 'createOrder',
+ method: 'post',
+ path: '/orders',
+ tags: ['Orders'],
+ requestBody: {
+ contentType: 'application/json',
+ required: true,
+ schema: {
+ kind: 'union',
+ members: [
+ { kind: 'ref', name: 'Order' },
+ { kind: 'enum', values: ['a', 'b', 'c', 'd', 'e', 'f', 'g'], scalar: 'string' },
+ ],
+ },
+ },
+ }),
+ operation({
+ name: 'streamEvents',
+ method: 'get',
+ path: '/events',
+ tags: ['Orders'],
+ successResponses: [
+ { status: 200, contentType: 'text/event-stream', schema: { kind: 'unknown' } },
+ ],
+ }),
+ operation({
+ name: 'downloadReport',
+ method: 'get',
+ path: '/report',
+ tags: [],
+ successResponses: [
+ {
+ status: 200,
+ contentType: 'application/octet-stream',
+ schema: { kind: 'unknown' },
+ },
+ ],
+ }),
+ ],
+ },
+ ],
+ securitySchemes: [
+ { key: 'BearerAuth', kind: 'bearer' },
+ { key: 'KeyAuth', kind: 'apiKeyHeader', headerName: 'X-Key' },
+ ],
+ });
+
+ const page = renderReferencePage(model, {
+ title: 'Cafe Python reference',
+ frontmatter: true,
+ language: LANGUAGE,
+ sample: (op) =>
+ op.name === 'listOrders' ? { lang: 'python', source: 'client.list_orders()\n' } : undefined,
+ paginated: new Set(['listOrders']),
+ });
+
+ expect(page).toContain('---\ntitle: Cafe Python reference\n---');
+ expect(page).toContain('Requires Python >= 3.9 and httpx.');
+ // Auth table covers both scheme spellings.
+ expect(page).toContain('| `BearerAuth` | bearer | `Authorization: Bearer ` |');
+ expect(page).toContain('| `KeyAuth` | apiKeyHeader | the `X-Key` header |');
+ // Tagged group, then the untagged fallback section.
+ expect(page).toContain('## Orders');
+ expect(page).toContain('## Operations');
+ // The sample rides in the language fence; a sample-less operation gets no fence.
+ expect(page).toContain('```python\nclient.list_orders()\n```');
+ // Summaries and descriptions are table-cell-safe: pipes escaped, newlines collapsed.
+ expect(page).toContain('List \\| orders.');
+ expect(page).toContain('| `cursor` | query | string | no | Page cursor. |');
+ expect(page).toContain('| `limit` | query | integer | yes | |');
+ // Type labels: refs by name, unions joined, long enums truncated.
+ expect(page).toContain('Returns `application/json`, of type OrderPage.');
+ expect(page).toContain('of type Order or enum: a, b, c, d, e, f, and 1 more.');
+ // The three declaration-level facts.
+ expect(page).toContain(
+ 'This operation is paginated, so the SDK gives it page and item iterators.'
+ );
+ expect(page).toContain(
+ 'This operation streams server-sent events, so the SDK iterates the events.'
+ );
+ expect(page).toContain('This operation returns binary content.');
+ // A bodyless, responseless operation would say "Returns no content." — createOrder has a
+ // body line instead.
+ expect(page).toContain('Body: `application/json`, required, of type Order or enum:');
+ });
+
+ it('falls back to config-resolved pagination and the no-schemes line without a resolved set', () => {
+ const model = apiModel({
+ services: [
+ {
+ name: 'Default',
+ operations: [
+ operation({
+ name: 'listItems',
+ specName: 'listItems',
+ method: 'get',
+ path: '/items',
+ queryParams: [param('offset', 'query', false, { kind: 'scalar', scalar: 'integer' })],
+ successResponses: [
+ {
+ status: 200,
+ contentType: 'application/json',
+ schema: { kind: 'array', items: { kind: 'scalar', scalar: 'string' } },
+ },
+ ],
+ }),
+ ],
+ },
+ ],
+ });
+ const page = renderReferencePage(model, {
+ title: 'Items reference',
+ frontmatter: false,
+ language: LANGUAGE,
+ sample: () => undefined,
+ pagination: { style: 'offset', offsetParam: 'offset', items: '' },
+ });
+ expect(page.startsWith('# Items reference')).toBe(true);
+ expect(page).toContain('The description declares no security schemes.');
+ expect(page).toContain('This operation is paginated');
+ expect(page).toContain('array of string');
+ });
+});
diff --git a/packages/client-generator/src/authoring/__tests__/schema.test.ts b/packages/client-generator/src/authoring/__tests__/schema.test.ts
index d1b156e7b4..0e42ad96be 100644
--- a/packages/client-generator/src/authoring/__tests__/schema.test.ts
+++ b/packages/client-generator/src/authoring/__tests__/schema.test.ts
@@ -102,6 +102,20 @@ describe('nullability and enums', () => {
});
expect(enumValues(STRING)).toBeUndefined();
});
+
+ it('keeps every member name usable: negatives, decimals, folds, and the empty string', () => {
+ // `VALUE_-1 = -1` is a SyntaxError in Python — the names must survive any value.
+ const votes: SchemaModel = { kind: 'enum', values: [-1, 1, 1.5, 15], scalar: 'number' };
+ expect(enumValues(votes)?.memberNames).toEqual([
+ 'VALUE_MINUS_1',
+ 'VALUE_1',
+ 'VALUE_1_5',
+ 'VALUE_15',
+ ]);
+ // Two values folding to one name stay distinct, and an empty value still gets a member.
+ const folds: SchemaModel = { kind: 'enum', values: ['a-b', 'a b', ''], scalar: 'string' };
+ expect(enumValues(folds)?.memberNames).toEqual(['A_B', 'A_B_2', '_']);
+ });
});
describe('docText', () => {
diff --git a/packages/client-generator/src/authoring/index.ts b/packages/client-generator/src/authoring/index.ts
index 67b9e36a7e..84624e96b3 100644
--- a/packages/client-generator/src/authoring/index.ts
+++ b/packages/client-generator/src/authoring/index.ts
@@ -18,6 +18,17 @@ export {
type ReferencePageOptions,
} from './reference-page.js';
export {
+ isMultipartBody,
+ jsonSuccessSchema,
+ paginationItemSchema,
+ securityRequirements,
+ serverUrlParts,
+ sseResponse,
+ type SecurityRequirement,
+ type ServerUrlPart,
+} from './operation.js';
+export {
+ deref,
discriminatorCases,
docText,
enumValues,
@@ -36,7 +47,14 @@ export const AUTHORING_HELPER_NAMES = [
'uniqueIdentifiers',
'RESERVED_WORDS',
'flattenAllOf',
+ 'deref',
'discriminatorCases',
+ 'jsonSuccessSchema',
+ 'sseResponse',
+ 'isMultipartBody',
+ 'serverUrlParts',
+ 'securityRequirements',
+ 'paginationItemSchema',
'isNullable',
'unwrapNullable',
'enumValues',
diff --git a/packages/client-generator/src/authoring/naming.ts b/packages/client-generator/src/authoring/naming.ts
index 0621d68182..cc0b6df21a 100644
--- a/packages/client-generator/src/authoring/naming.ts
+++ b/packages/client-generator/src/authoring/naming.ts
@@ -1,6 +1,6 @@
// Language-neutral naming: one word splitter, four casings, and an identifier
// sanitizer parameterized by the target language's reserved words. TypeScript
-// keeps its specialized sanitizer in emitters/identifier.ts; this is for the
+// keeps its specialized sanitizer in the TypeScript printer; this is for the
// other output languages.
/** Split on delimiters and camel/acronym boundaries: 'APIKey-v2' → ['api', 'key', 'v2']. */
diff --git a/packages/client-generator/src/authoring/operation.ts b/packages/client-generator/src/authoring/operation.ts
new file mode 100644
index 0000000000..f33572eabd
--- /dev/null
+++ b/packages/client-generator/src/authoring/operation.ts
@@ -0,0 +1,113 @@
+// Language-neutral operation-shape helpers: the questions every generator asks of an
+// operation before deciding what to emit — which response is the JSON success, whether it
+// streams, whether the body is multipart. One answer each, so two generators cannot
+// disagree about the same operation.
+
+import type {
+ ApiModel,
+ OperationModel,
+ ResponseBodyModel,
+ SchemaModel,
+ ServerModel,
+} from '../intermediate-representation/model.js';
+import { schemaAtPointer } from './schema.js';
+
+/** The schema of the operation's primary JSON success response, if it has one. */
+export function jsonSuccessSchema(op: OperationModel): SchemaModel | undefined {
+ return op.successResponses.find((response) => response.contentType.toLowerCase().includes('json'))
+ ?.schema;
+}
+
+/** The `text/event-stream` success response — present exactly when the operation streams. */
+export function sseResponse(op: OperationModel): ResponseBodyModel | undefined {
+ return op.successResponses.find((response) =>
+ response.contentType.toLowerCase().includes('text/event-stream')
+ );
+}
+
+/** Whether the request body is multipart (any `multipart/*` content type). */
+export function isMultipartBody(op: OperationModel): boolean {
+ return op.requestBody?.contentType.toLowerCase().includes('multipart') ?? false;
+}
+
+/** One piece of a parsed server-URL template: literal text, or a declared variable's name. */
+export type ServerUrlPart = { kind: 'literal'; value: string } | { kind: 'variable'; name: string };
+
+/**
+ * A server's URL template as parts a generator concatenates in its own syntax:
+ * `https://{region}.api.example.com/v1` → literal, variable `region`, literal. A variable
+ * the server does not declare has nothing to substitute, so its placeholder stays literal
+ * text and remains visible in the generated code.
+ */
+export function serverUrlParts(server: ServerModel): ServerUrlPart[] {
+ const declared = new Set(server.variables.map((variable) => variable.name));
+ const parts: ServerUrlPart[] = [];
+ let literal = '';
+ let rest = server.url;
+ const template = /\{([^{}]+)\}/;
+ for (let match = template.exec(rest); match !== null; match = template.exec(rest)) {
+ literal += rest.slice(0, match.index);
+ if (declared.has(match[1])) {
+ if (literal !== '') parts.push({ kind: 'literal', value: literal });
+ literal = '';
+ parts.push({ kind: 'variable', name: match[1] });
+ } else {
+ literal += match[0];
+ }
+ rest = rest.slice(match.index + match[0].length);
+ }
+ literal += rest;
+ if (literal !== '' || parts.length === 0) parts.push({ kind: 'literal', value: literal });
+ return parts;
+}
+
+/** One resolved security requirement: the scheme's key, kind, and (for apiKey) placement. */
+export type SecurityRequirement =
+ | { scheme: string; kind: 'bearer' | 'basic' }
+ | { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' };
+
+/**
+ * The operation's security as OR-alternatives of AND-sets, denormalized against the
+ * declared schemes — the shape every generated runtime's auth resolver consumes. A key that
+ * names no declared scheme is dropped, and an alternative that ends up empty with it.
+ * Generators print this in their own literal syntax; the mapping itself has one answer.
+ */
+export function securityRequirements(
+ op: OperationModel,
+ model: Pick
+): SecurityRequirement[][] {
+ return op.security
+ .map((alternative) =>
+ alternative.flatMap((key): SecurityRequirement[] => {
+ const scheme = model.securitySchemes.find((candidate) => candidate.key === key);
+ if (scheme === undefined) return [];
+ if (scheme.kind === 'bearer' || scheme.kind === 'basic') {
+ return [{ scheme: key, kind: scheme.kind }];
+ }
+ if (scheme.kind === 'apiKeyHeader') {
+ return [{ scheme: key, kind: 'apiKey', name: scheme.headerName, in: 'header' }];
+ }
+ if (scheme.kind === 'apiKeyQuery') {
+ return [{ scheme: key, kind: 'apiKey', name: scheme.paramName, in: 'query' }];
+ }
+ return [{ scheme: key, kind: 'apiKey', name: scheme.cookieName, in: 'cookie' }];
+ })
+ )
+ .filter((alternative) => alternative.length > 0);
+}
+
+/**
+ * The element type of a paginated operation's items: resolve the rule's `items` pointer to
+ * the items ARRAY, then take its raw element — a `ref` element keeps its class name (a
+ * deref'd result would hydrate as plain data). Undefined when the pointer misses or the
+ * target is not an array.
+ */
+export function paginationItemSchema(
+ pageSchema: SchemaModel | undefined,
+ itemsPointer: string | undefined,
+ model: ApiModel
+): SchemaModel | undefined {
+ if (pageSchema === undefined || itemsPointer === undefined) return undefined;
+ const itemsArray = schemaAtPointer(pageSchema, itemsPointer, model);
+ return itemsArray?.kind === 'array' ? itemsArray.items : undefined;
+}
diff --git a/packages/client-generator/src/authoring/pagination.ts b/packages/client-generator/src/authoring/pagination.ts
index cec9c69218..de2ccf0686 100644
--- a/packages/client-generator/src/authoring/pagination.ts
+++ b/packages/client-generator/src/authoring/pagination.ts
@@ -4,7 +4,7 @@
// (schema-level advance-param/pointer checks) remains generation-side; this helper is
// what every language generator shares.
-import type { ApiModel, OperationModel } from '../intermediate-representation/model.js';
+import type { OperationModel } from '../intermediate-representation/model.js';
/** The normalized rule a generator renders into its runtime's pagination spec. */
export type NeutralPaginationRule = {
@@ -25,8 +25,7 @@ export type NeutralPaginationRule = {
*/
export function paginationRuleFor(
op: OperationModel,
- config: Record | undefined,
- _model?: ApiModel
+ config: Record | undefined
): NeutralPaginationRule | undefined {
const configuration = config ?? {};
const id = op.specName ?? op.name;
diff --git a/packages/client-generator/src/authoring/reference-page.ts b/packages/client-generator/src/authoring/reference-page.ts
index 41827d4853..1e6ee05f18 100644
--- a/packages/client-generator/src/authoring/reference-page.ts
+++ b/packages/client-generator/src/authoring/reference-page.ts
@@ -35,6 +35,8 @@ export type ReferencePageOptions = {
sample: (operation: OperationModel) => { lang: string; source: string } | undefined;
/** The `pagination` config, passed through to `paginationRuleFor`. */
pagination?: Record;
+ /** Operation names the RUN resolved as paginated — preferred over re-resolving. */
+ paginated?: ReadonlySet;
};
/** Table-cell-safe text: one line, with pipes and backslashes escaped. */
@@ -135,7 +137,11 @@ function writeOperation(printer: Printer, op: OperationModel, options: Reference
// The same three declaration-level facts every SDK reads: `paginationRuleFor` is the
// helper the language generators resolve pagination with, and the success content type
// is what decides a streaming or a binary response.
- if (paginationRuleFor(op, options.pagination)) {
+ const paginates =
+ options.paginated !== undefined
+ ? options.paginated.has(op.name)
+ : paginationRuleFor(op, options.pagination) !== undefined;
+ if (paginates) {
printer.line('This operation is paginated, so the SDK gives it page and item iterators.');
}
if (op.successResponses.some((response) => response.contentType === 'text/event-stream')) {
diff --git a/packages/client-generator/src/authoring/schema.ts b/packages/client-generator/src/authoring/schema.ts
index 4dbe4b436c..2e1f88de97 100644
--- a/packages/client-generator/src/authoring/schema.ts
+++ b/packages/client-generator/src/authoring/schema.ts
@@ -8,10 +8,10 @@ import type {
PropertyModel,
SchemaModel,
} from '../intermediate-representation/model.js';
-import { casing } from './naming.js';
+import { casing, uniqueIdentifiers } from './naming.js';
/** Follow a `ref` chain through the model's named schemas; undefined on a miss or cycle. */
-function deref(schema: SchemaModel, model: ApiModel): SchemaModel | undefined {
+export function deref(schema: SchemaModel, model: ApiModel): SchemaModel | undefined {
const seen = new Set();
let current = schema;
while (current.kind === 'ref') {
@@ -84,8 +84,16 @@ export function enumValues(
schema: SchemaModel
): { values: Array; scalar: string; memberNames: string[] } | undefined {
if (schema.kind !== 'enum') return undefined;
- const memberNames = schema.values.map((value) =>
- typeof value === 'string' ? casing.screaming(value) : `VALUE_${String(value).toUpperCase()}`
+ // `casing` owns the value-to-word rules (`-1` → `MINUS_1`), and `uniqueIdentifiers` owns
+ // the rest of "language-safe": two values may fold to one name (`a-b` and `a b`), and an
+ // empty string folds to nothing at all — both must still yield distinct usable members.
+ const memberNames = uniqueIdentifiers(
+ schema.values.map((value) =>
+ typeof value === 'string'
+ ? casing.screaming(value)
+ : `VALUE_${casing.screaming(String(value))}`
+ ),
+ { style: 'screaming' }
);
return { values: schema.values, scalar: schema.scalar, memberNames };
}
diff --git a/packages/client-generator/src/cli-contract.ts b/packages/client-generator/src/cli-contract.ts
new file mode 100644
index 0000000000..e042bf6671
--- /dev/null
+++ b/packages/client-generator/src/cli-contract.ts
@@ -0,0 +1,142 @@
+// The generated-CLI authoring contract: the command/wiring shapes a wrapper around a
+// generated or composed CLI is written against, plus the two casing helpers the cli
+// generator shares with the engine. Defined at package level (ADR-0022: contracts own
+// their types); the engine re-exports them, and the prepare-time snapshot splices the
+// definitions back into the embedded module so generated CLIs stay self-contained.
+
+/** One flag derived from a query parameter. */
+export type CliFlag = {
+ /** Kebab-cased flag name (`--page-size`). */
+ name: string;
+ /** Original wire parameter name. */
+ param: string;
+ type: 'string' | 'number' | 'boolean' | 'array';
+ required: boolean;
+ enum?: string[];
+ description?: string;
+};
+
+/** One executable command, derived from the IR at generate time. Pure data. */
+export type CliCommand = {
+ /** Tag; absent = flat/untagged. */
+ group?: string;
+ name: string;
+ summary?: string;
+ method: string;
+ path: string;
+ /** Path params, in path-template order. Always required — that is what a path is. */
+ positionals: Array<{
+ name: string;
+ type?: CliFlag['type'];
+ description?: string;
+ }>;
+ flags: CliFlag[];
+ /**
+ * Present when the operation takes a JSON request body. `merged` marks a body whose own
+ * properties a flat-style call spells at the top level (the generator decides this from
+ * the schema, so the CLI and the client can never disagree).
+ */
+ body?: { required: boolean; merged?: boolean };
+ /**
+ * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).
+ * `--json` cannot build one, so the command is reported as library-only rather than
+ * offered as if it were runnable.
+ */
+ unsupportedBody?: string;
+ paginated?: boolean;
+ /** `'grouped'` marks a command whose client method takes namespaced inputs even on a
+ * flat-style client, because its merged names would collide. */
+ argsStyle?: 'grouped';
+ sse?: boolean;
+ blob?: boolean;
+ /** IR schemas for the `schema` command, serialized verbatim. */
+ schemas?: { request?: unknown; response?: unknown };
+};
+
+export type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };
+
+export type CliWiring = {
+ /** The name the CLI is invoked as, for help output only. The generated entry reads it
+ * from `process.argv[1]`, so help never names a command that is not installed. */
+ name: string;
+ /** Credential variable prefix, constant-cased: `CAFE` gives `CAFE_TOKEN`. Fixed at
+ * generation from the output file name, so renaming the binary keeps the variables
+ * a published CLI already documents. A composed entry sets one per api alias. */
+ envPrefix: string;
+ /** The generated instance client. */
+ client: Record;
+ /** How that client takes its inputs. Defaults to `'grouped'`, the generated default. */
+ argsStyle?: 'grouped' | 'flat';
+ configure: (config: Record) => void;
+ /** Security schemes of the API — drives env-var credential resolution. */
+ schemes?: CliAuthScheme[];
+ env?: Record;
+ stdin?: () => string;
+ readFile?: (path: string) => string;
+ writeFile?: (path: string, data: Uint8Array) => void;
+ stdout: (line: string) => void;
+ stderr: (line: string) => void;
+};
+
+export type CliGlobals = {
+ serverUrl?: string;
+ format?: 'json' | 'ndjson';
+ dryRun?: boolean;
+ pageAll?: boolean;
+ output?: string;
+ token?: string;
+ json?: string;
+};
+
+/**
+ * A hand-written command composed NEXT TO the generated ones: the same data shape plus a
+ * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is
+ * how behavior that is not in the description (a `login`, a doctor command) joins the
+ * binary without the generator ever learning what it does.
+ */
+export type CustomCommand = {
+ name: string;
+ group?: string;
+ summary?: string;
+ positionals?: CliCommand['positionals'];
+ flags?: CliFlag[];
+ /** Returns the process exit code; throwing exits 1 with the standard error JSON. */
+ handler: (context: CommandContext) => number | Promise;
+};
+
+export type CommandContext = {
+ positionals: Record;
+ params: Record;
+ globals: CliGlobals;
+ wiring: CliWiring;
+};
+
+/** One API's contribution to a composed binary: its commands behind a namespace, with its
+ * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */
+export type CommandSource = {
+ namespace?: string;
+ commands: Array;
+ /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */
+ wiring?: CliWiring;
+};
+
+/**
+ * The shell-typable form of a group name: an OpenAPI tag can contain spaces ("Some
+ * multi-word tag"), which only resolves if the user quotes it. Commands are addressed by
+ * this slug; help still shows the original tag.
+ */
+export function groupSlug(group: string): string {
+ return group
+ .toLowerCase()
+ .split(/[^a-z0-9]+/)
+ .filter(Boolean)
+ .join('-');
+}
+
+/** `cafe-api` → `CAFE_API`: the casing of every credential variable this CLI reads. */
+export function constantCase(value: string): string {
+ return value
+ .replace(/[^A-Za-z0-9]+/g, '_')
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
+ .toUpperCase();
+}
diff --git a/packages/client-generator/src/emitters/__tests__/operation-signature.test.ts b/packages/client-generator/src/contracts/__tests__/typescript.test.ts
similarity index 94%
rename from packages/client-generator/src/emitters/__tests__/operation-signature.test.ts
rename to packages/client-generator/src/contracts/__tests__/typescript.test.ts
index de6528fc27..71bdea69cf 100644
--- a/packages/client-generator/src/emitters/__tests__/operation-signature.test.ts
+++ b/packages/client-generator/src/contracts/__tests__/typescript.test.ts
@@ -1,5 +1,5 @@
-import { operationSignature, templatePathParams } from '../operation-signature.js';
-import { operation, param } from './fixtures.js';
+import { operation, param } from '../../__tests__/fixtures.js';
+import { operationSignature, templatePathParams } from '../typescript.js';
describe('operationSignature', () => {
it('orders path params by URL-template position, keeping their wire names', () => {
diff --git a/packages/client-generator/src/emitters/wrapper-support.ts b/packages/client-generator/src/contracts/typescript.ts
similarity index 77%
rename from packages/client-generator/src/emitters/wrapper-support.ts
rename to packages/client-generator/src/contracts/typescript.ts
index 76d5710d02..38bdfbca83 100644
--- a/packages/client-generator/src/emitters/wrapper-support.ts
+++ b/packages/client-generator/src/contracts/typescript.ts
@@ -1,15 +1,13 @@
-// Shared support for the data-fetching wrapper generators (`swr`, `tanstack-query`).
-// Both wrap the sdk's exported operation functions, so they agree on which operations
-// are wrappable and on the `vars`/`init` parameter shape. Keeping that agreement in one
-// place stops the two emitters from drifting (and makes a third adapter cheap). The
-// per-operation factory/hook bodies stay in each emitter — only the cross-cutting
-// calling-convention pieces live here.
+// The published output ABI of the `typescript` generator — what its emitted SDK
+// exports and how a call is spelled. Importable ONLY along a declared `requires`
+// edge (`swr`, `tanstack-query`, and `cli` require `typescript`): duplicating these
+// answers in each wrapper would put the SDK's calling convention in several places,
+// which is exactly the drift this module exists to prevent.
import { logger } from '@redocly/openapi-core';
+import { operationSignature } from '../generators/typescript/operation-signature.js';
import type { ApiModel, OperationModel } from '../intermediate-representation/model.js';
-import { operationSignature } from './operation-signature.js';
-import { isSseOp } from './sse.js';
/**
* The operations a wrapper generator can wrap, with skips reported to the user under
@@ -24,7 +22,7 @@ import { isSseOp } from './sse.js';
*/
export function wrappableOperations(model: ApiModel, label: string): OperationModel[] {
const all = model.services.flatMap((s) => s.operations);
- const sse = all.filter(isSseOp);
+ const sse = all.filter((op) => op.sse !== undefined);
if (sse.length > 0) {
logger.warn(
`generate-client: ${label} skipped ${sse.length} server-sent-events operation(s) — iterate the sdk's exported async generators directly: ${sse
@@ -33,7 +31,7 @@ export function wrappableOperations(model: ApiModel, label: string): OperationMo
);
}
const schemaNames = new Set(model.schemas.map((s) => s.name));
- const clashing = all.filter((op) => !isSseOp(op) && collides(op, schemaNames));
+ const clashing = all.filter((op) => op.sse === undefined && collides(op, schemaNames));
if (clashing.length > 0) {
logger.warn(
`generate-client: ${label} skipped ${clashing.length} operation(s) whose variables type name collides with a schema — rename the schema or the operation: ${clashing
@@ -41,7 +39,7 @@ export function wrappableOperations(model: ApiModel, label: string): OperationMo
.join(', ')}.\n`
);
}
- return all.filter((op) => !isSseOp(op) && !collides(op, schemaNames));
+ return all.filter((op) => op.sse === undefined && !collides(op, schemaNames));
}
/** Whether the operation's `Variables` type name collides with a named schema. */
@@ -95,3 +93,13 @@ export function sdkNamedImportText(
const specifiers = [...values, ...types.map((name) => `type ${name}`)].join(', ');
return `import { ${specifiers} } from ${JSON.stringify(sdkModule)};`;
}
+
+// The pieces the typescript generator decides itself, published for the generators
+// that must agree with it: the per-operation signature facts and whether an
+// operation's inputs merge into one flat object (`cli` renders the same call shape).
+export {
+ operationSignature,
+ templatePathParams,
+ type OperationSignature,
+} from '../generators/typescript/operation-signature.js';
+export { flatInputShape } from '../generators/typescript/render-client.js';
diff --git a/packages/client-generator/src/emitters/__tests__/identifier.test.ts b/packages/client-generator/src/emitters/__tests__/identifier.test.ts
deleted file mode 100644
index fa5bc15b80..0000000000
--- a/packages/client-generator/src/emitters/__tests__/identifier.test.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { isIdentifier, safeIdent, uniqueIdent } from '../identifier.js';
-
-describe('isIdentifier', () => {
- it('accepts valid identifiers (letters, _, $, digits after the first char)', () => {
- expect(isIdentifier('foo')).toBe(true);
- expect(isIdentifier('_foo')).toBe(true);
- expect(isIdentifier('$foo')).toBe(true);
- expect(isIdentifier('foo123')).toBe(true);
- });
-
- it('rejects names that are not valid identifiers', () => {
- expect(isIdentifier('foo-bar')).toBe(false);
- expect(isIdentifier('2fa')).toBe(false);
- expect(isIdentifier('has space')).toBe(false);
- expect(isIdentifier('')).toBe(false);
- });
-});
-
-describe('safeIdent', () => {
- it('returns a valid, non-reserved name bare', () => {
- expect(safeIdent('limit')).toBe('limit');
- });
-
- it('quotes a reserved word (a bare reserved word would not be a usable key)', () => {
- expect(safeIdent('default')).toBe('"default"');
- });
-
- it('quotes a name that is not a valid identifier', () => {
- expect(safeIdent('X-Request-Id')).toBe('"X-Request-Id"');
- });
-});
-
-describe('uniqueIdent', () => {
- it('keeps a clean identifier unchanged and records it', () => {
- const used = new Set();
- expect(uniqueIdent('orderId', used)).toBe('orderId');
- expect(used.has('orderId')).toBe(true);
- });
-
- it('replaces non-identifier characters with underscores', () => {
- expect(uniqueIdent('pet-id', new Set())).toBe('pet_id');
- });
-
- it('prefixes a leading digit with an underscore', () => {
- expect(uniqueIdent('2fa', new Set())).toBe('_2fa');
- });
-
- it('prefixes a reserved word with an underscore', () => {
- expect(uniqueIdent('new', new Set())).toBe('_new');
- });
-
- it('treats strict-mode reserved words as reserved (modules are always strict)', () => {
- // GitHub's real description has a schema named `package`; `type X = package[]` is TS1214.
- expect(uniqueIdent('package', new Set())).toBe('_package');
- expect(uniqueIdent('let', new Set())).toBe('_let');
- expect(uniqueIdent('await', new Set())).toBe('_await');
- });
-
- it('suffixes collisions with an incrementing counter', () => {
- const used = new Set();
- expect(uniqueIdent('a.b', used)).toBe('a_b');
- expect(uniqueIdent('a-b', used)).toBe('a_b_2');
- expect(uniqueIdent('a b', used)).toBe('a_b_3');
- });
-});
diff --git a/packages/client-generator/src/emitters/__tests__/runtime-sources.test.ts b/packages/client-generator/src/emitters/__tests__/runtime-sources.test.ts
deleted file mode 100644
index 8c304cd504..0000000000
--- a/packages/client-generator/src/emitters/__tests__/runtime-sources.test.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { readdirSync, readFileSync } from 'node:fs';
-import { dirname, join } from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-import { RUNTIME_SOURCES } from '../runtime-sources.js';
-
-const runtimeDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'runtime');
-
-describe('runtime-sources', () => {
- it('the generated snapshot matches src/runtime (every module except the barrel)', () => {
- const expected = Object.fromEntries(
- readdirSync(runtimeDir)
- .filter((name) => name.endsWith('.ts') && name !== 'index.ts')
- .map((name) => [name, readFileSync(join(runtimeDir, name), 'utf-8')])
- );
- expect(
- { ...RUNTIME_SOURCES },
- 'emitters/runtime-sources.ts is stale — run `npm run prepare -w @redocly/client-generator`'
- ).toEqual(expected);
- });
-});
diff --git a/packages/client-generator/src/emitters/__tests__/sse.test.ts b/packages/client-generator/src/emitters/__tests__/sse.test.ts
deleted file mode 100644
index dacea5a0cf..0000000000
--- a/packages/client-generator/src/emitters/__tests__/sse.test.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import type { ResponseBodyModel, SchemaModel } from '../../intermediate-representation/model.js';
-import { eventSchema, isSseOp, sseDataKind } from '../sse.js';
-import { operation } from './fixtures.js';
-
-/** An operation whose success response streams `text/event-stream`. */
-function sseOp(response: Partial, name = 'streamMessages') {
- return operation({
- name,
- successResponses: [
- { contentType: 'text/event-stream', schema: { kind: 'unknown' }, ...response, status: 200 },
- ],
- });
-}
-
-describe('isSseOp', () => {
- it('is true for a success response with the text/event-stream content type', () => {
- expect(isSseOp(sseOp({}))).toBe(true);
- });
-
- it('matches with parameters and is case-insensitive', () => {
- expect(isSseOp(sseOp({ contentType: 'text/event-stream; charset=utf-8' }))).toBe(true);
- expect(isSseOp(sseOp({ contentType: 'Text/Event-Stream' }))).toBe(true);
- });
-
- it('is false for a plain JSON operation', () => {
- expect(
- isSseOp(
- operation({
- successResponses: [
- { contentType: 'application/json', schema: { kind: 'ref', name: 'Pet' }, status: 200 },
- ],
- })
- )
- ).toBe(false);
- });
-
- it('is false for an operation with no responses', () => {
- expect(isSseOp(operation({}))).toBe(false);
- });
-});
-
-describe('eventSchema (drives the streamed payload type)', () => {
- it('uses the per-item schema when present', () => {
- expect(eventSchema(sseOp({ itemSchema: { kind: 'ref', name: 'Message' } }))).toEqual({
- kind: 'ref',
- name: 'Message',
- });
- });
-
- it('falls back to the response schema when it is meaningful', () => {
- expect(eventSchema(sseOp({ schema: { kind: 'ref', name: 'Token' } }))).toEqual({
- kind: 'ref',
- name: 'Token',
- });
- });
-
- it('ignores a typeless `itemSchema` and falls back to the response schema', () => {
- expect(
- eventSchema(
- sseOp({ itemSchema: { kind: 'unknown' }, schema: { kind: 'ref', name: 'Token' } })
- )
- ).toEqual({ kind: 'ref', name: 'Token' });
- });
-
- it('is undefined when no schema is declared (payload types as `string`)', () => {
- expect(eventSchema(sseOp({}))).toBeUndefined();
- expect(eventSchema(operation({}))).toBeUndefined();
- });
-});
-
-describe('sseDataKind', () => {
- it("is 'json' for object/ref/array/record/union/intersection event types", () => {
- const json: SchemaModel[] = [
- { kind: 'object', properties: [] },
- { kind: 'ref', name: 'Message' },
- { kind: 'array', items: { kind: 'scalar', scalar: 'string' } },
- { kind: 'record', value: { kind: 'scalar', scalar: 'string' } },
- { kind: 'union', members: [{ kind: 'ref', name: 'A' }] },
- { kind: 'intersection', members: [{ kind: 'ref', name: 'A' }] },
- ];
- for (const itemSchema of json) expect(sseDataKind(sseOp({ itemSchema }))).toBe('json');
- });
-
- it("is 'text' for the string fallback (no schema)", () => {
- expect(sseDataKind(sseOp({}))).toBe('text');
- });
-
- it("is 'text' for a typeless `itemSchema` (no meaningful schema)", () => {
- expect(sseDataKind(sseOp({ itemSchema: { kind: 'unknown' } }))).toBe('text');
- });
-
- it("is 'text' for scalar/literal/enum/null event types", () => {
- const text: SchemaModel[] = [
- { kind: 'scalar', scalar: 'string' },
- { kind: 'literal', value: 'x' },
- { kind: 'enum', values: ['a'], scalar: 'string' },
- { kind: 'null' },
- ];
- for (const itemSchema of text) expect(sseDataKind(sseOp({ itemSchema }))).toBe('text');
- });
-});
diff --git a/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts b/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts
deleted file mode 100644
index 540ff8e705..0000000000
--- a/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { codeLiteral, sanitizeCodeString } from '../ts-literal.js';
-
-// Literal expectations for the data-literal renderer (single-line, printer-style).
-const CASES: Array<[string, unknown]> = [
- ['string', 'plain'],
- ['string with quotes and backslashes', 'say "hi" \\ done'],
- ['string with newline', 'a\nb'],
- ['number', 42],
- ['negative number', -3.5],
- ['booleans', true],
- ['null', null],
- ['empty array', []],
- ['array', ['a', 1, false]],
- ['empty object', {}],
- ['flat object', { id: 'getPet', method: 'GET', count: 2 }],
- ['reserved-word key stays bare', { in: 'query', name: 'limit' }],
- ['non-identifier key is quoted', { 'X-Request-Id': 'header', 'a-b': 1 }],
- [
- 'nested descriptor-like shape',
- {
- id: 'listOrders',
- path: '/orders/{id}',
- params: [
- { name: 'id', in: 'path' },
- { name: 'page-size', in: 'query', explode: false },
- ],
- security: [[{ scheme: 'Bearer', kind: 'bearer' }]],
- pagination: { style: 'cursor', cursorParam: 'after', items: '/items' },
- },
- ],
-];
-
-describe('codeLiteral', () => {
- it.each(CASES)('%s', (_label, value) => {
- expect(codeLiteral(value)).toMatchSnapshot();
- });
-});
-
-describe('sanitizeCodeString', () => {
- // The literal must survive being read back: a sanitizer that escapes what
- // `JSON.stringify` already escaped doubles the backslashes and, for a quote, ends the
- // string early — emitting TypeScript that does not parse.
- it.each([
- ['a newline', 'a\nb'],
- ['a quote', 'quote " here'],
- ['a backslash', 'C:\\path'],
- ['a tab', 'tab\there'],
- ['a line separator', 'a\u2028b'],
- ['everything at once', 'a\n"b"\\c\u2029'],
- ])('round-trips %s', (_label, value) => {
- expect(JSON.parse(sanitizeCodeString(value))).toBe(value);
- expect(JSON.parse(codeLiteral(value) as string)).toBe(value);
- });
-
- it('escapes the characters that break out of a code context', () => {
- // `` must not survive intact into an inline script.
- expect(sanitizeCodeString('')).not.toContain('');
- expect(sanitizeCodeString('')).toContain('\\u003C');
- expect(sanitizeCodeString('a\u2028b')).toContain('\\u2028');
- });
-});
diff --git a/packages/client-generator/src/emitters/emit-options.ts b/packages/client-generator/src/emitters/emit-options.ts
deleted file mode 100644
index de87942898..0000000000
--- a/packages/client-generator/src/emitters/emit-options.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import type { ApiModel } from '../intermediate-representation/model.js';
-import { escapeJsDoc } from './jsdoc.js';
-import type { ArgsStyle } from './operations.js';
-import type { PaginationConfig } from './pagination.js';
-import { splitLines } from './support.js';
-import type { DateType } from './types.js';
-
-// The public option vocabulary is re-exported from this module, so generators
-// and the package barrel import the emitter surface from one place.
-export type { ArgsStyle } from './operations.js';
-
-/** The generated-by banner prepended to every emitted module. */
-export const HEADER = `// Generated by @redocly/client-generator — do not edit by hand.
-// Source: OpenAPI description. Re-run \`redocly generate-client\` to update.`;
-
-export type EmitOptions = {
- /**
- * Override the server URL baked into the generated client config. When omitted,
- * the value is derived from `servers[0].url` in the source OpenAPI description.
- */
- serverUrl?: string;
- /**
- * How operation inputs are passed to each call. Defaults to `'flat'`;
- * `'grouped'` bundles inputs into a single `args` object.
- */
- argsStyle?: ArgsStyle;
- /** Error-handling shape of the generated client. Defaults to `'throw'`. */
- errorMode?: 'throw' | 'result';
- /**
- * How `format: date-time`/`date` string fields are typed. `'string'` (default)
- * keeps the ISO wire shape; `'Date'` emits a `Date` reference. Opt-in — pair with
- * the `transformers` generator so the runtime value matches the type.
- */
- dateType?: DateType;
- /**
- * How the `mock` generator produces data. `'static'` (default) inlines deterministic
- * literals (zero-dep, contract-faithful); `'faker'` emits `@faker-js/faker` calls for
- * realistic data — reproducible when `mockSeed` is set. Only the mock module is affected.
- */
- mockData?: 'static' | 'faker';
- /** Seed for faker-mode mocks: emits a top-level `faker.seed()` so runs reproduce. */
- mockSeed?: number;
- /** Leading element for every tanstack-query key — namespaces the cache when several
- * generated APIs share one QueryClient (operationIds may collide across APIs). */
- queryKeyPrefix?: string;
- /**
- * A pre-baked publisher setup block (from `bakeSetup`) merged into the client's config
- * via `mergeSetup`. Absent when no `--setup` is given.
- */
- setup?: string;
- /** Runtime distribution: 'inline' (default, self-contained) | 'package' (imports @redocly/client-generator). */
- runtime?: 'inline' | 'package';
- /**
- * Extension used in generated relative import specifiers (the split entry's schemas
- * re-export and each satellite's sdk import). `'js'` (default) is the tsc/bundler
- * convention; `'ts'` targets runtimes that resolve specifiers literally, like Node's
- * built-in type stripping (`node client.ts`).
- */
- importExt?: 'js' | 'ts';
- /**
- * Package clause of the `go` generator's output. Defaults to `client` — a generated
- * file usually lands in a package the consumer already owns, so the name is theirs
- * to choose. An invalid Go package name fails generation.
- */
- goPackage?: string;
- /**
- * Auto-pagination rules (a convention rule + per-operation overrides + `exclude`),
- * resolved together with each operation's `x-redoclyPagination` extension. Verified
- * statically: an explicit rule that doesn't fit its operation fails generation.
- */
- pagination?: PaginationConfig;
- /**
- * Also write the reference documentation for what each selected generator emits: one
- * Markdown page per generator that implements the `docs` hook. One switch for the whole
- * run, so a new documented language never needs a new flag.
- */
- docs?: boolean;
- /** Emit YAML front matter carrying the title above each documentation page. */
- docsFrontmatter?: boolean;
-};
-
-/**
- * Assemble file content from a header banner and a printed body: the leading
- * `// Generated by …` comment and the `/** title */` block are structural
- * banners (not part of the printed AST), prepended with blank-line separation.
- * Trailing newline mirrors a hand-authored file.
- */
-export function banner(sections: string[]): string {
- return sections.filter((s) => s.length > 0).join('\n\n') + '\n';
-}
-
-export function renderTitleComment(model: ApiModel): string {
- // This banner is a raw string (it does not flow through `jsdoc()`), so escape
- // `*/` here too — `info.title`/`info.description` are attacker-controllable and
- // would otherwise close the comment and inject code at the top of every file.
- const lines = [`/**`, ` * ${escapeJsDoc(`${model.title} (v${model.version})`)}`];
- if (model.description) {
- for (const line of splitLines(escapeJsDoc(model.description))) {
- // A blank line prints as ` *` — a trailing space fails consumer formatter checks.
- lines.push(` * ${line}`.replace(/ +$/, ''));
- }
- }
- lines.push(' */');
- return lines.join('\n');
-}
diff --git a/packages/client-generator/src/emitters/identifier.ts b/packages/client-generator/src/emitters/identifier.ts
deleted file mode 100644
index 1130855593..0000000000
--- a/packages/client-generator/src/emitters/identifier.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-// Identifier sanitization — mapping OpenAPI names (which may contain `-`, `.`,
-// spaces, or be reserved words) onto valid TypeScript identifiers. Pure string
-// logic with no dependency on the IR or other emitters.
-
-/** Matches a string that is already a valid JS identifier (ignoring reserved words). */
-const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
-
-const TS_RESERVED = new Set([
- 'break',
- 'case',
- 'catch',
- 'class',
- 'const',
- 'continue',
- 'debugger',
- 'default',
- 'delete',
- 'do',
- 'else',
- 'enum',
- 'export',
- 'extends',
- 'false',
- 'finally',
- 'for',
- 'function',
- 'if',
- 'import',
- 'in',
- 'instanceof',
- 'new',
- 'null',
- 'return',
- 'super',
- 'switch',
- 'this',
- 'throw',
- 'true',
- 'try',
- 'typeof',
- 'var',
- 'void',
- 'while',
- 'with',
- 'yield',
- // Strict-mode reserved words — generated files are ES modules, always strict.
- 'await',
- 'implements',
- 'interface',
- 'let',
- 'package',
- 'private',
- 'protected',
- 'public',
- 'static',
-]);
-
-/** True when `name` matches the JS identifier grammar (reserved words still pass). */
-export function isIdentifier(name: string): boolean {
- return IDENT_RE.test(name);
-}
-
-/** True when `name` is a valid JS identifier AND not a reserved word — safe as a binding name. */
-export function isSafeIdentifier(name: string): boolean {
- return IDENT_RE.test(name) && !TS_RESERVED.has(name);
-}
-
-/**
- * Coerce an arbitrary spec-supplied name into a valid, non-reserved JS identifier
- * (no uniqueness guarantee — see `uniqueIdent`). Non-identifier characters become
- * `_`; an empty result, a leading digit, or a reserved word is prefixed with `_`.
- * This is the security boundary for any name that lands in a declaration slot —
- * `ts.factory.createIdentifier` prints its text verbatim, so an unsanitized name
- * like `foo(){};evil()` would emit as executable code.
- */
-export function sanitizeIdentifier(name: string): string {
- let base = name.replace(/[^A-Za-z0-9_$]/g, '_');
- if (base === '' || /^[0-9]/.test(base) || TS_RESERVED.has(base)) base = `_${base}`;
- return base;
-}
-
-/**
- * A double-quoted TS string literal for generated code. `JSON.stringify` alone leaves
- * U+2028/U+2029 raw (legal JSON, line terminators in code contexts) — escape them so a
- * hostile spec value can never alter the shape of the emitted statement.
- */
-export function codeString(value: string): string {
- return JSON.stringify(value)
- .replace(/\u2028/g, '\\u2028')
- .replace(/\u2029/g, '\\u2029');
-}
-
-/**
- * Render `name` as an object key or property name: bare when it is a valid,
- * non-reserved identifier, quoted otherwise. Safe only where quoting is legal
- * (object keys, property signatures) — not for binding names; use `uniqueIdent`
- * there.
- */
-export function safeIdent(name: string): string {
- if (IDENT_RE.test(name) && !TS_RESERVED.has(name)) {
- return name;
- }
- return codeString(name);
-}
-
-/**
- * `sanitizeIdentifier(name)` made unique within `used` (which it mutates):
- * collisions get a `_2`, `_3`, … suffix. Used wherever a name lands in a binding
- * slot that — unlike an object key — cannot be quoted (function/type/parameter
- * names), so `safeIdent`'s quote-on-failure fallback would not compile.
- */
-export function uniqueIdent(name: string, used: Set): string {
- const base = sanitizeIdentifier(name);
- let ident = base;
- let n = 2;
- while (used.has(ident)) ident = `${base}_${n++}`;
- used.add(ident);
- return ident;
-}
diff --git a/packages/client-generator/src/emitters/jsdoc.ts b/packages/client-generator/src/emitters/jsdoc.ts
deleted file mode 100644
index c9a7f304b8..0000000000
--- a/packages/client-generator/src/emitters/jsdoc.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import type { SchemaMetadata } from '../intermediate-representation/model.js';
-import { splitLines } from './support.js';
-
-/** Backslash-escape any comment-closing star-slash so it cannot terminate a block comment. */
-export function escapeJsDoc(text: string): string {
- return text.replace(/\*\//g, '*\\/');
-}
-
-/**
- * The JSDoc body for a description + metadata as a single `\n`-joined string,
- * or `undefined` when there's nothing to document. The AST emitters feed this
- * to `ts.ts`'s `jsdoc` helper (which owns the `*`-prefixing and indentation),
- * so this returns only the raw body — no comment delimiters, no padding.
- */
-export function jsdocText(text: string | undefined, metadata?: SchemaMetadata): string | undefined {
- const lines = jsdocLines(text, metadata);
- return lines.length === 0 ? undefined : lines.join('\n');
-}
-
-/**
- * Build the body of a JSDoc block from a description and an optional metadata
- * bag. Description lines come first (trimmed of leading/trailing blanks); then
- * the metadata tag lines in a stable, source-driven order.
- *
- * Returns `[]` when there's nothing to render — callers use the empty result
- * to skip emitting any JSDoc at all.
- */
-function jsdocLines(text: string | undefined, metadata: SchemaMetadata | undefined): string[] {
- const lines: string[] = [];
- if (text && text.trim()) {
- lines.push(...trimLines(splitLines(text)));
- }
- if (metadata) {
- lines.push(...formatMetadata(metadata));
- }
- return lines;
-}
-
-/**
- * Project a SchemaMetadata bag into JSDoc tag lines.
- *
- * Order matches the (near-)spec order so generated output is deterministic and
- * diff-stable. `pattern` is escaped so an embedded `*/` cannot terminate the
- * surrounding JSDoc block.
- */
-function formatMetadata(metadata: SchemaMetadata): string[] {
- const lines: string[] = [];
- const push = (tag: string, value?: number | string | boolean): void => {
- if (value === undefined) {
- lines.push(`@${tag}`);
- } else {
- lines.push(`@${tag} ${value}`);
- }
- };
- if (metadata.minimum !== undefined) push('minimum', metadata.minimum);
- if (metadata.maximum !== undefined) push('maximum', metadata.maximum);
- if (metadata.exclusiveMinimum !== undefined) push('exclusiveMinimum', metadata.exclusiveMinimum);
- if (metadata.exclusiveMaximum !== undefined) push('exclusiveMaximum', metadata.exclusiveMaximum);
- if (metadata.minLength !== undefined) push('minLength', metadata.minLength);
- if (metadata.maxLength !== undefined) push('maxLength', metadata.maxLength);
- if (metadata.pattern !== undefined) push('pattern', escapeJsDoc(metadata.pattern));
- if (metadata.minItems !== undefined) push('minItems', metadata.minItems);
- if (metadata.maxItems !== undefined) push('maxItems', metadata.maxItems);
- if (metadata.uniqueItems === true) push('uniqueItems');
- if (metadata.format !== undefined) push('format', metadata.format);
- if (metadata.deprecated === true) push('deprecated');
- return lines;
-}
-
-function trimLines(lines: string[]): string[] {
- let start = 0;
- let end = lines.length;
- while (start < end && lines[start] === '') start++;
- while (end > start && lines[end - 1] === '') end--;
- return lines.slice(start, end);
-}
diff --git a/packages/client-generator/src/emitters/operations.ts b/packages/client-generator/src/emitters/operations.ts
deleted file mode 100644
index 1e5c169a56..0000000000
--- a/packages/client-generator/src/emitters/operations.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import type { NamedSchemaModel } from '../intermediate-representation/model.js';
-import type { ModelPagination } from './pagination.js';
-import type { DateType } from './types.js';
-
-/** Error-handling shape of the generated client: throw on non-2xx, or return a result union. */
-export type ErrorMode = 'throw' | 'result';
-
-/**
- * How an operation's inputs are passed to the generated call.
- * - `'flat'` (default): path params spread as positional args, then the
- * `params`/`body`/`headers` slots — one exported sugar arrow per operation.
- * - `'grouped'`: the client methods' own shape — a single `args` object bundling
- * every input; the sugar is a plain destructure of the client. The per-call
- * `init: RequestOptions` stays a separate trailing argument in both styles.
- */
-export type ArgsStyle = 'flat' | 'grouped';
-
-/**
- * The emit configuration every operation shares. Bundling it into one value keeps
- * it out of the positional parameter lists of the operation emitters (which would
- * otherwise thread the same arguments through every layer, inviting transposition
- * bugs). Per-call structural data (response type, ordered path params, …) stays an
- * explicit argument; only this cross-cutting config travels as `ctx`.
- */
-export type EmitContext = {
- argsStyle: ArgsStyle;
- errorMode: ErrorMode;
- dateType: DateType;
- /** Names of every exported schema, used for `*` alias collision suppression. */
- schemaNames: Set;
- /** Named schemas — used to resolve `$ref` / `allOf` wrappers on response-header types. */
- schemas?: readonly NamedSchemaModel[];
- /** Resolved auto-pagination per operation name (absent ⇒ nothing paginates). */
- pagination?: ModelPagination;
-};
diff --git a/packages/client-generator/src/emitters/sse.ts b/packages/client-generator/src/emitters/sse.ts
deleted file mode 100644
index a91d1bbc1e..0000000000
--- a/packages/client-generator/src/emitters/sse.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import type {
- OperationModel,
- ResponseBodyModel,
- SchemaModel,
-} from '../intermediate-representation/model.js';
-
-/** The media type that marks an operation as a Server-Sent Events stream. */
-const SSE_CONTENT_TYPE = 'text/event-stream';
-
-/** The event-stream success response of an operation, if it declares one. */
-function sseResponse(op: OperationModel): ResponseBodyModel | undefined {
- return op.successResponses.find(
- (r) => r.contentType.split(';')[0].trim().toLowerCase() === SSE_CONTENT_TYPE
- );
-}
-
-/** Whether an operation streams Server-Sent Events. */
-export function isSseOp(op: OperationModel): boolean {
- return sseResponse(op) !== undefined;
-}
-
-/** The per-event schema: `itemSchema` → the response `schema` → undefined (typeless slots skipped). */
-export function eventSchema(op: OperationModel): SchemaModel | undefined {
- const r = sseResponse(op);
- if (!r) return undefined;
- if (r.itemSchema && r.itemSchema.kind !== 'unknown') return r.itemSchema;
- if (r.schema.kind !== 'unknown') return r.schema;
- return undefined;
-}
-
-/** Whether the streamed `data:` payload should be `JSON.parse`d (`'json'`) or passed raw (`'text'`). */
-export function sseDataKind(op: OperationModel): 'json' | 'text' {
- const schema = eventSchema(op);
- if (!schema) return 'text';
- return schema.kind === 'object' ||
- schema.kind === 'ref' ||
- schema.kind === 'array' ||
- schema.kind === 'record' ||
- schema.kind === 'union' ||
- schema.kind === 'intersection'
- ? 'json'
- : 'text';
-}
diff --git a/packages/client-generator/src/emitters/support.ts b/packages/client-generator/src/emitters/support.ts
deleted file mode 100644
index 8844646229..0000000000
--- a/packages/client-generator/src/emitters/support.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-// 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
- * contain digits or `_` are passed through unchanged — the user named them that
- * way for a reason.
- *
- * `op.name` reaches here already sanitized into a non-empty, valid TS identifier
- * by the IR builder (see `intermediate-representation/sanitize-identifiers.ts`), so no empty-string or
- * unsafe-character guard is needed.
- */
-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')
- .split('\n')
- .map((line) => line.trimEnd());
-}
diff --git a/packages/client-generator/src/emitters/ts-literal.ts b/packages/client-generator/src/emitters/ts-literal.ts
deleted file mode 100644
index 86008af0b9..0000000000
--- a/packages/client-generator/src/emitters/ts-literal.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-// Plain data → TypeScript expression text. Single-line (`{ a: 1, b: [2, 3] }`);
-// keys stay bare when they pass the identifier GRAMMAR (reserved words are legal
-// object-literal keys), quoted otherwise.
-
-import { isIdentifier } from './identifier.js';
-
-// `JSON.stringify` already produces a valid TypeScript string literal: it escapes quotes,
-// backslashes, and every control character. What it leaves literal is what can still break
-// out of a CODE context — `<` and `>` (a `` sequence when the output is embedded
-// in an inline script) and U+2028/U+2029, which are line terminators in JS source but not
-// in JSON. Only those are escaped here, and only on the stringified text, which contains
-// no raw backslashes to double.
-const CODE_UNSAFE: Record = {
- '<': '\\u003C',
- '>': '\\u003E',
- '\u2028': '\\u2028',
- '\u2029': '\\u2029',
-};
-
-/** A string as a TypeScript literal that cannot escape the code context it lands in. */
-export function sanitizeCodeString(value: string): string {
- return JSON.stringify(value).replace(/[<>\u2028\u2029]/g, (char) => CODE_UNSAFE[char]);
-}
-
-/** A JSON-ish value as TypeScript source text. */
-export function codeLiteral(value: unknown): string {
- if (typeof value === 'string') return sanitizeCodeString(value);
- if (typeof value === 'boolean' || value === null) return String(value);
- if (typeof value === 'number') return String(value);
- if (Array.isArray(value)) {
- return `[${value.map(codeLiteral).join(', ')}]`;
- }
- const entries = Object.entries(value as Record).map(
- ([key, entryValue]) =>
- `${isIdentifier(key) ? key : sanitizeCodeString(key)}: ${codeLiteral(entryValue)}`
- );
- return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`;
-}
diff --git a/packages/client-generator/src/emitters/types.ts b/packages/client-generator/src/emitters/types.ts
deleted file mode 100644
index 30bce7b741..0000000000
--- a/packages/client-generator/src/emitters/types.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-// The TS emitters' shared option types. `DateType` is a NEUTRAL option (every
-// language honors it), so it is defined in the authoring toolkit and re-exported
-// here for the emitters that have always imported it from this module.
-
-export type { DateType } from '../authoring/options.js';
diff --git a/packages/client-generator/src/generate.ts b/packages/client-generator/src/generate.ts
index 5bcbf3aeb6..720f61166f 100644
--- a/packages/client-generator/src/generate.ts
+++ b/packages/client-generator/src/generate.ts
@@ -5,9 +5,13 @@
// root: package-mode clients import the root at app runtime, and the root reaches
// the pipeline only through a dynamic import.
-import type { EmitOptions } from './emitters/emit-options.js';
import { builtinGenerators, validateGenerators } from './generators/index.js';
-import type { GeneratedFile, GeneratorDescriptor, OutputMode } from './generators/types.js';
+import type {
+ EmitOptions,
+ GeneratedFile,
+ GeneratorDescriptor,
+ OutputMode,
+} from './generators/types.js';
import type { ApiModel } from './intermediate-representation/model.js';
import { runGenerators } from './pipeline.js';
@@ -16,15 +20,15 @@ import { runGenerators } from './pipeline.js';
// when every built-in generator migrated to text (one authoring model for every
// output language). `tsType`/`tsJsdoc`/`codeLiteral` are the TypeScript-specific
// text renderers the sdk itself uses.
-export { tsJsdoc, tsType } from './emitters/ts-type.js';
-export { codeLiteral } from './emitters/ts-literal.js';
+export { tsJsdoc, tsType } from './generators/typescript/ts-type.js';
+export { codeLiteral } from './printers/typescript.js';
// The language-neutral authoring helpers, re-exported here so both toolkit
// entries offer the full authoring surface (the root offers them TS-free).
export * from './authoring/index.js';
-export { operationSignature } from './emitters/operation-signature.js';
-export type { OperationSignature } from './emitters/operation-signature.js';
-export { pascalCase } from './emitters/support.js';
-export { safeIdent } from './emitters/identifier.js';
+export { operationSignature } from './contracts/typescript.js';
+export type { OperationSignature } from './contracts/typescript.js';
+export { pascalCase } from './printers/typescript.js';
+export { safeIdent } from './printers/typescript.js';
/**
* Validate the generator selection (see `validateGenerators`), then run each
@@ -52,4 +56,4 @@ export function collectGeneratedFiles(
export { generateClient } from './pipeline.js';
// The composed-cli entry renderer: consumed by the redocly CLI across apis (it needs the
// embedded runtime text, which must stay off the runtime-only root barrel).
-export { renderComposedCliEntry, type ComposedCliSource } from './emitters/cli.js';
+export { renderComposedCliEntry, type ComposedCliSource } from './generators/cli/render.js';
diff --git a/packages/client-generator/src/generators/__tests__/cli.test.ts b/packages/client-generator/src/generators/__tests__/cli.test.ts
index b33eca6fed..efe9a57916 100644
--- a/packages/client-generator/src/generators/__tests__/cli.test.ts
+++ b/packages/client-generator/src/generators/__tests__/cli.test.ts
@@ -1,6 +1,11 @@
import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js';
-import { cliGenerator, cliSample } from '../cli/index.js';
+import { cliGenerator as cliGeneratorEntry, cliSample } from '../cli/index.js';
import { builtinGenerators, validateGenerators } from '../index.js';
+import { generatorInput } from './fixtures/generator-input.js';
+
+// The pipeline parses the output anchor; `generatorInput` mirrors it for direct calls.
+const cliGenerator = (input: Parameters[0]) =>
+ cliGeneratorEntry(generatorInput(input));
const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' };
diff --git a/packages/client-generator/src/generators/__tests__/fixtures/generator-input.ts b/packages/client-generator/src/generators/__tests__/fixtures/generator-input.ts
new file mode 100644
index 0000000000..9564225bd9
--- /dev/null
+++ b/packages/client-generator/src/generators/__tests__/fixtures/generator-input.ts
@@ -0,0 +1,28 @@
+import { parse } from 'node:path';
+
+import { resolveModelPagination } from '../../../pagination.js';
+import type { GeneratorInput } from '../../types.js';
+
+/** The banner lines the pipeline derives from `HEADER` for every run. */
+export const BANNER = [
+ 'Generated by @redocly/client-generator — do not edit by hand.',
+ 'Source: OpenAPI description. Re-run `redocly generate-client` to update.',
+];
+
+/**
+ * Build a `GeneratorInput` the way the pipeline does: parse the `--output` anchor,
+ * stamp the banner, and resolve pagination once. Tests call generators directly, so
+ * they mirror those steps here.
+ */
+export function generatorInput(
+ overrides: Omit & { outputPath: string }
+): GeneratorInput {
+ const { outputPath, ...rest } = overrides;
+ const { dir, name: stem, ext } = parse(outputPath);
+ return {
+ ...rest,
+ output: { path: outputPath, dir, stem, ext },
+ banner: BANNER,
+ pagination: resolveModelPagination(overrides.model, undefined),
+ };
+}
diff --git a/packages/client-generator/src/generators/__tests__/fixtures/route-map-plugin.ts b/packages/client-generator/src/generators/__tests__/fixtures/route-map-plugin.ts
index a5009cff29..446fb37180 100644
--- a/packages/client-generator/src/generators/__tests__/fixtures/route-map-plugin.ts
+++ b/packages/client-generator/src/generators/__tests__/fixtures/route-map-plugin.ts
@@ -4,14 +4,14 @@ import type { CustomGenerator } from '../../types.js';
const generator: CustomGenerator = {
name: 'route-map',
requires: ['typescript'],
- run({ model, outputPath }) {
+ run({ model, output }) {
const routes = model.services
.flatMap((s) => s.operations)
.map((op) => ` ${op.name}: '${op.method.toUpperCase()} ${op.path}',`)
.join('\n');
return [
{
- path: outputPath.replace(/\.ts$/, '.routes.ts'),
+ path: output.path.replace(/\.ts$/, '.routes.ts'),
content: `export const routes = {\n${routes}\n} as const;\n`,
},
];
diff --git a/packages/client-generator/src/generators/__tests__/generator-options.test.ts b/packages/client-generator/src/generators/__tests__/generator-options.test.ts
index c3aa0e0f07..669f56c901 100644
--- a/packages/client-generator/src/generators/__tests__/generator-options.test.ts
+++ b/packages/client-generator/src/generators/__tests__/generator-options.test.ts
@@ -98,9 +98,9 @@ describe('runGenerators', () => {
'permissions-matrix',
{
options: MATRIX_SCHEMA,
- run: ({ options, outputPath }) => {
+ run: ({ options, output }) => {
seen = options;
- return [{ path: outputPath.replace(/\.ts$/, '.permissions.md'), content: '' }];
+ return [{ path: output.path.replace(/\.ts$/, '.permissions.md'), content: '' }];
},
},
],
diff --git a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts
index f763aac2fe..aa15050a15 100644
--- a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts
+++ b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts
@@ -3,7 +3,7 @@ import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// The prepare-time transform that rewrites the repo-facing intro and modify loop
-// into their user-repo equivalents (plain .mjs, importable straight from scripts/).
+// into their user-repo equivalents (importable straight from scripts/).
import { ejectedSkill } from '../../../scripts/ejected-skill.mjs';
// Skill-first development: EVERY generator lives in a folder with its own AGENTS.md —
@@ -11,11 +11,19 @@ import { ejectedSkill } from '../../../scripts/ejected-skill.mjs';
// missing its modify-loop anchors, fails here.
const generatorsDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
-/** Language generators: one self-contained file, ejected as its own source. */
-const LANGUAGE = ['python', 'go', 'php'];
-/** TypeScript generators: thin entries over shared emitters, ejected bundled with them. */
-const TYPESCRIPT = ['typescript', 'zod', 'mock', 'cli', 'swr', 'tanstack-query', 'transformers'];
-const EJECTABLE = [...LANGUAGE, ...TYPESCRIPT];
+/** Every generator: a self-contained folder, ejected as its own source. */
+const EJECTABLE = [
+ 'python',
+ 'go',
+ 'php',
+ 'typescript',
+ 'zod',
+ 'mock',
+ 'cli',
+ 'swr',
+ 'tanstack-query',
+ 'transformers',
+];
describe.each(EJECTABLE)('%s generator skill', (name) => {
const skillPath = join(generatorsDir, name, 'AGENTS.md');
@@ -32,38 +40,23 @@ describe.each(EJECTABLE)('%s generator skill', (name) => {
});
});
-describe.each(LANGUAGE)('%s generator skill ships to users', (name) => {
- const skillPath = join(generatorsDir, name, 'AGENTS.md');
-
- it('names its runtime', () => {
- expect(readFileSync(skillPath, 'utf-8')).toContain(`runtime/${name}/`);
- });
-
- it('ships without repo-only references — the user has no index.ts, prepare, or vitest', () => {
+describe.each(EJECTABLE)('%s generator skill ships to users', (name) => {
+ it('ships without repo-only references — the user has no prepare script or vitest', () => {
const asset = join(generatorsDir, '../../eject-assets/skills', `${name}-generator`, 'SKILL.md');
const shipped = readFileSync(asset, 'utf-8');
- expect(shipped).toContain(`generators/${name}.mjs`);
- expect(shipped).not.toContain('index.ts');
+ expect(shipped).toContain(`generators/${name}/`);
expect(shipped).not.toContain('npm run prepare');
expect(shipped).not.toContain('vitest');
});
});
-describe.each(TYPESCRIPT)('%s generator skill (bundled on eject)', (name) => {
- it('points at the emitters that implement it and says what ejecting ships', () => {
- const skill = readFileSync(join(generatorsDir, name, 'AGENTS.md'), 'utf-8');
- expect(skill).toContain('## Emitters that implement it');
- expect(skill).toContain('## Ejecting it');
- // The two packages a bundled generator imports — the user installs both.
- expect(skill).toContain('@redocly/openapi-core');
- });
-});
-
describe.each(EJECTABLE)('%s ships an eject asset', (name) => {
const assetsDir = join(generatorsDir, '../../eject-assets');
+ // Every generator ships as its source folder, entry index.ts.
+ const assetEntry = join(assetsDir, 'generators', name, 'index.ts');
it('has a generator asset and a skill beside it', () => {
- expect(existsSync(join(assetsDir, 'generators', `${name}.mjs`))).toBe(true);
+ expect(existsSync(assetEntry)).toBe(true);
const skill = readFileSync(join(assetsDir, 'skills', `${name}-generator`, 'SKILL.md'), 'utf-8');
expect(skill.startsWith(`---\nname: ${name}-generator\ndescription: `)).toBe(true);
});
@@ -80,8 +73,7 @@ describe.each(EJECTABLE)('%s ships an eject asset', (name) => {
});
it('declares the default export the resolver loads, with a version range', () => {
- // The bundled assets go through esbuild, which normalizes quotes — match either.
- const asset = readFileSync(join(assetsDir, 'generators', `${name}.mjs`), 'utf-8');
+ const asset = readFileSync(assetEntry, 'utf-8');
expect(asset).toMatch(new RegExp(`name: ['"]${name}['"]`));
expect(asset).toMatch(/requiresGenerator: ['"]\^\d+\.\d+\.\d+['"]/);
expect(asset).toContain('Ejected from @redocly/client-generator@');
diff --git a/packages/client-generator/src/generators/__tests__/go-runtime-embed.test.ts b/packages/client-generator/src/generators/__tests__/go-runtime-embed.test.ts
index 875f57ddd4..40ca26ca18 100644
--- a/packages/client-generator/src/generators/__tests__/go-runtime-embed.test.ts
+++ b/packages/client-generator/src/generators/__tests__/go-runtime-embed.test.ts
@@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
-import { GO_RUNTIME_SOURCE } from '../../emitters/go-runtime-sources.js';
+import { GO_RUNTIME_SOURCE } from '../../runtime-sources/go.js';
const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const hasGo = spawnSync('go', ['version']).status === 0;
@@ -27,7 +27,7 @@ describe('GO_RUNTIME_SOURCE (the embedded Go runtime)', () => {
it.skipIf(!hasGo)('the runtime module passes go vet', () => {
const result = spawnSync('go', ['vet', './...'], {
- cwd: join(pkgRoot, 'runtime', 'go'),
+ cwd: join(pkgRoot, 'src', 'generators', 'go', 'runtime'),
encoding: 'utf-8',
});
expect(result.status, result.stderr).toBe(0);
diff --git a/packages/client-generator/src/generators/__tests__/go.test.ts b/packages/client-generator/src/generators/__tests__/go.test.ts
index 2dc7cc59d1..d0a01acc11 100644
--- a/packages/client-generator/src/generators/__tests__/go.test.ts
+++ b/packages/client-generator/src/generators/__tests__/go.test.ts
@@ -4,7 +4,13 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js';
-import { goGenerator, renderGoModels } from '../go/index.js';
+import { goGenerator as goGeneratorEntry, goSample, renderGoModels } from '../go/index.js';
+import { generatorInput } from './fixtures/generator-input.js';
+
+// The pipeline parses the output anchor and resolves pagination once; `generatorInput`
+// mirrors those steps for these direct calls.
+const goGenerator = (input: Parameters[0]) =>
+ goGeneratorEntry(generatorInput(input));
const hasGo = spawnSync('go', ['version']).status === 0;
@@ -219,6 +225,12 @@ const CAFE: ApiModel = {
queryParams: [
{ name: 'after', in: 'query', required: false, schema: STRING },
{ name: 'limit', in: 'query', required: false, schema: INT },
+ {
+ name: 'tags',
+ in: 'query',
+ required: false,
+ schema: { kind: 'array', items: STRING },
+ },
],
headerParams: [],
cookieParams: [],
@@ -343,6 +355,7 @@ const CAFE: ApiModel = {
schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } },
required: true,
},
+ { name: 'next', schema: STRING, required: false },
],
},
},
@@ -391,6 +404,43 @@ describe('goGenerator (full client assembly)', () => {
});
});
+describe('query and sample shapes', () => {
+ it('an array query param repeats the key per element — fmt.Sprint would send "[a b]"', () => {
+ const out = generateGo();
+ expect(out).toContain('for _, item := range *params.Tags {');
+ expect(out).toContain('query.Add("tags", item)');
+ expect(out).not.toContain('query.Set("tags"');
+ });
+
+ it('the sample assignment matches the return shape: void has no result, SSE is one value', () => {
+ const ctx = { model: CAFE, outputPath: '/out/client.ts', emit: {} };
+ const listOrders = CAFE.services[0].operations.find((op) => op.name === 'listOrders')!;
+ expect(goSample(listOrders, ctx)?.source).toContain('result, err := client.ListOrders(');
+ const streamEvents = CAFE.services[0].operations.find((op) => op.name === 'streamEvents')!;
+ expect(goSample(streamEvents, ctx)?.source).toContain('stream := client.StreamEvents(');
+ });
+});
+
+describe('goGenerator runtime: module', () => {
+ it('writes runtime.go in the same package and prunes the client imports to its own uses', () => {
+ const files = goGenerator({
+ model: CAFE,
+ outputPath: '/out/client.ts',
+ outputMode: 'single',
+ emit: { runtime: 'module' },
+ });
+ const runtime = files.find((file) => file.path === '/out/runtime.go')!.content;
+ expect(runtime).toContain('\npackage client\n');
+ expect(runtime.startsWith('// Generated by @redocly/client-generator')).toBe(true);
+ const entry = files.find((file) => file.path === '/out/client.go')!.content;
+ expect(entry).not.toContain('// ─── Embedded runtime');
+ // Retry backoff is runtime machinery: an unused import is a Go compile error,
+ // so the client's import block must not carry it.
+ expect(entry).not.toContain('"math/rand"');
+ expect(entry).toContain('"encoding/json"');
+ });
+});
+
describe('goGenerator parity features', () => {
it('paginated operations gain Pages/Items yield-func iterators with typed elements', () => {
const out = generateGo();
diff --git a/packages/client-generator/src/generators/__tests__/index.test.ts b/packages/client-generator/src/generators/__tests__/index.test.ts
index 1f6616cffa..766ee0d439 100644
--- a/packages/client-generator/src/generators/__tests__/index.test.ts
+++ b/packages/client-generator/src/generators/__tests__/index.test.ts
@@ -77,7 +77,7 @@ describe('validateGenerators', () => {
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
try {
// `outputMode` travels beside `emit`, hence the trailing argument.
- validateGenerators(['php'], { runtime: 'package', argsStyle: 'grouped' }, undefined, 'split');
+ validateGenerators(['php'], { runtime: 'inline', argsStyle: 'grouped' }, undefined, 'split');
const messages = warn.mock.calls.map(([message]) => message).join('');
expect(messages).toContain('the "php" generator ignores outputMode');
expect(messages).toContain('the "php" generator ignores runtime');
@@ -92,7 +92,7 @@ describe('validateGenerators', () => {
warn.mockClear();
validateGenerators(
['typescript'],
- { runtime: 'package', argsStyle: 'grouped' },
+ { runtime: 'inline', argsStyle: 'grouped' },
undefined,
'split'
);
@@ -136,38 +136,6 @@ describe('swr generator', () => {
});
});
-describe('validateGenerators — runtime compatibility', () => {
- /** A registry with one runtimes-restricted generator (no built-in restricts runtimes anymore). */
- function registryWith(runtimes: ('inline' | 'package')[]) {
- const registry = builtinGenerators();
- registry.set('inline-only', { run: () => [], runtimes });
- return registry;
- }
-
- it('rejects a runtimes-restricted generator with runtime: package, naming both', () => {
- expect(() =>
- validateGenerators(['inline-only'], { runtime: 'package' }, registryWith(['inline']))
- ).toThrow(/"inline-only".*runtime "package".*inline/);
- });
-
- it('accepts a runtimes-restricted generator when the runtime matches (or is defaulted)', () => {
- expect(() =>
- validateGenerators(['inline-only'], { runtime: 'inline' }, registryWith(['inline']))
- ).not.toThrow();
- expect(() => validateGenerators(['inline-only'], {}, registryWith(['inline']))).not.toThrow();
- });
-
- it('accepts the wrapper generators with runtime: package (no longer restricted)', () => {
- expect(() =>
- validateGenerators(
- ['typescript', 'tanstack-query', 'swr'],
- { runtime: 'package' },
- builtinGenerators()
- )
- ).not.toThrow();
- });
-});
-
describe('mock generator', () => {
it('is registered and requires typescript', () => {
expect(builtinGenerators().get('mock')?.requires).toContain('typescript');
diff --git a/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts b/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts
index 0912c5d00b..986bf7dc43 100644
--- a/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts
+++ b/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts
@@ -1,33 +1,66 @@
-import { readFileSync } from 'node:fs';
+import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
-// The python generator is the flywheel's proof: it must be authored EXACTLY the
-// way the AGENTS.md skill teaches users' agents — with the language-neutral
-// toolkit only. Any import outside this allowlist (in particular the TS emitter
-// toolkit) is a dogfooding violation, and also breaks the promise that a
-// python-only selection never loads the `typescript` package.
-const ALLOWED_SPECIFIERS = new Set([
- '../../authoring/index.js',
- '../../emitters/python-runtime-sources.js', // pure embedded strings, generated at prepare time
- '../../emitters/go-runtime-sources.js',
- '../../emitters/php-runtime-sources.js',
- '../../intermediate-representation/model.js', // type-only IR shapes
- '../types.js', // the generator contract
-]);
+// Every built-in generator must be authored EXACTLY the way the AGENTS.md skill
+// teaches users' agents — through the public package specifiers an ejected copy
+// carries (a tsconfig `paths` entry resolves them to src). Any import outside a
+// folder's allowlist is a dogfooding violation; for the language generators it also
+// breaks the promise that a python-only selection never loads `typescript`.
+//
+// The sharing tiers (ADR-0020): the neutral toolkit, the folder's OWN printer —
+// never another language's — the runtime sources, and a required generator's
+// published contract. Node builtins and `@redocly/openapi-core` (the toolkit's own
+// dependency) are platform, not sharing.
+const SHARED_SPECIFIERS = [
+ '@redocly/client-generator',
+ '@redocly/client-generator/runtime-sources',
+ '@redocly/openapi-core',
+];
-describe.each(['python/index.ts', 'go/index.ts', 'php/index.ts'])(
- '%s dogfooding invariant',
- (file) => {
- it('imports only what the authoring skill offers to any custom generator', () => {
- const source = readFileSync(
- resolve(dirname(fileURLToPath(import.meta.url)), '..', file),
- 'utf-8'
+const GENERATORS: Array<{ name: string; printer?: string; contracts?: string[] }> = [
+ { name: 'python', printer: 'python' },
+ { name: 'go', printer: 'go' },
+ { name: 'php', printer: 'php' },
+ { name: 'typescript', printer: 'typescript' },
+ { name: 'zod', printer: 'typescript' },
+ { name: 'mock', printer: 'typescript' },
+ { name: 'transformers', printer: 'typescript' },
+ // The wrappers and the cli code against the typescript SDK's published ABI —
+ // the `requires: ['typescript']` edge in the registry.
+ { name: 'swr', printer: 'typescript', contracts: ['typescript'] },
+ { name: 'tanstack-query', printer: 'typescript', contracts: ['typescript'] },
+ { name: 'cli', printer: 'typescript', contracts: ['typescript'] },
+];
+
+describe.each(GENERATORS)('$name folder dogfooding invariant', ({ name, printer, contracts }) => {
+ it('imports only what the authoring skill offers to any custom generator', () => {
+ const folder = resolve(dirname(fileURLToPath(import.meta.url)), '..', name);
+ // Top-level stage files only: a `runtime/` subfolder holds the embedded runtime's
+ // own sources, which keep their intra-runtime relative imports by design.
+ const stageFiles = readdirSync(folder).filter(
+ (entry) => entry.endsWith('.ts') && statSync(resolve(folder, entry)).isFile()
+ );
+ expect(stageFiles.length).toBeGreaterThan(0);
+ const allowed = new Set([
+ ...SHARED_SPECIFIERS,
+ `@redocly/client-generator/printers/${printer}`,
+ ...(contracts ?? []).map((required) => `@redocly/client-generator/contracts/${required}`),
+ ]);
+ for (const file of stageFiles) {
+ const source = readFileSync(resolve(folder, file), 'utf-8');
+ // Real module imports only — generators also EMIT import lines inside template
+ // literals (`'msw'`, `'./runtime/factory.${ext}'`), which are output, not imports.
+ const specifiers = [...source.matchAll(/^(?:import|export|\}).* from '([^']+)';$/gm)].map(
+ (match) => match[1]
+ );
+ const violations = specifiers.filter(
+ (specifier) =>
+ !allowed.has(specifier) &&
+ !/^\.\/[a-z-]+\.ts$/.test(specifier) &&
+ !specifier.startsWith('node:')
);
- const specifiers = [...source.matchAll(/from '([^']+)'/g)].map((match) => match[1]);
- expect(specifiers.length).toBeGreaterThan(0);
- const violations = specifiers.filter((specifier) => !ALLOWED_SPECIFIERS.has(specifier));
- expect(violations).toEqual([]);
- });
- }
-);
+ expect(violations, `${name}/${file}`).toEqual([]);
+ }
+ });
+});
diff --git a/packages/client-generator/src/generators/__tests__/mock.test.ts b/packages/client-generator/src/generators/__tests__/mock.test.ts
index 53a00049ff..bdc4778cdd 100644
--- a/packages/client-generator/src/generators/__tests__/mock.test.ts
+++ b/packages/client-generator/src/generators/__tests__/mock.test.ts
@@ -1,5 +1,10 @@
-import { apiModel, namedSchema, operation, response } from '../../emitters/__tests__/fixtures.js';
-import { mockGenerator } from '../mock/index.js';
+import { apiModel, namedSchema, operation, response } from '../../__tests__/fixtures.js';
+import { mockGenerator as mockGeneratorEntry } from '../mock/index.js';
+import { generatorInput } from './fixtures/generator-input.js';
+
+// The pipeline parses the output anchor; `generatorInput` mirrors it for direct calls.
+const mockGenerator = (input: Parameters[0]) =>
+ mockGeneratorEntry(generatorInput(input));
describe('mockGenerator', () => {
it('returns [] for a model with no operations', () => {
diff --git a/packages/client-generator/src/generators/__tests__/php-runtime-embed.test.ts b/packages/client-generator/src/generators/__tests__/php-runtime-embed.test.ts
index 87a5d5cd51..2e2ccc59f3 100644
--- a/packages/client-generator/src/generators/__tests__/php-runtime-embed.test.ts
+++ b/packages/client-generator/src/generators/__tests__/php-runtime-embed.test.ts
@@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
-import { PHP_RUNTIME_SOURCE } from '../../emitters/php-runtime-sources.js';
+import { PHP_RUNTIME_SOURCE } from '../../runtime-sources/php.js';
const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const hasPhp = spawnSync('php', ['--version']).status === 0;
@@ -27,7 +27,7 @@ describe('PHP_RUNTIME_SOURCE (the embedded PHP runtime)', () => {
it.skipIf(!hasPhp)('the runtime module passes php -l', () => {
const result = spawnSync('php', ['-l', 'runtime.php'], {
- cwd: join(pkgRoot, 'runtime', 'php'),
+ cwd: join(pkgRoot, 'src', 'generators', 'php', 'runtime'),
encoding: 'utf-8',
});
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
diff --git a/packages/client-generator/src/generators/__tests__/php.test.ts b/packages/client-generator/src/generators/__tests__/php.test.ts
index 30963fd5af..81d45215d0 100644
--- a/packages/client-generator/src/generators/__tests__/php.test.ts
+++ b/packages/client-generator/src/generators/__tests__/php.test.ts
@@ -4,7 +4,18 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js';
-import { phpGenerator, phpType, renderPhpModels } from '../php/index.js';
+import {
+ phpGenerator as phpGeneratorEntry,
+ phpSample,
+ phpType,
+ renderPhpModels,
+} from '../php/index.js';
+import { generatorInput } from './fixtures/generator-input.js';
+
+// The pipeline parses the output anchor and resolves pagination once; `generatorInput`
+// mirrors those steps for these direct calls.
+const phpGenerator = (input: Parameters[0]) =>
+ phpGeneratorEntry(generatorInput(input));
const hasPhp = spawnSync('php', ['--version']).status === 0;
@@ -481,6 +492,103 @@ function generatePhp(): string {
return files[0].content;
}
+describe('method names are unique across the client', () => {
+ const colliding: ApiModel = {
+ title: 'Collide',
+ version: '1.0.0',
+ serverUrl: 'https://api.example.com',
+ schemas: [],
+ securitySchemes: [],
+ services: [
+ {
+ name: 'Default',
+ operations: [
+ {
+ name: 'get_user',
+ specName: 'get-user',
+ method: 'get',
+ path: '/users/{id}',
+ tags: [],
+ pathParams: [
+ {
+ name: 'id',
+ in: 'path',
+ required: true,
+ schema: { kind: 'scalar', scalar: 'string' },
+ },
+ ],
+ queryParams: [],
+ headerParams: [],
+ cookieParams: [],
+ security: [],
+ successResponses: [],
+ errorResponses: [],
+ },
+ {
+ name: 'getUser',
+ specName: 'getUser',
+ method: 'get',
+ path: '/users/by-name/{name}',
+ tags: [],
+ pathParams: [
+ {
+ name: 'name',
+ in: 'path',
+ required: true,
+ schema: { kind: 'scalar', scalar: 'string' },
+ },
+ ],
+ queryParams: [],
+ headerParams: [],
+ cookieParams: [],
+ security: [],
+ successResponses: [],
+ errorResponses: [],
+ },
+ ],
+ },
+ ],
+ } as unknown as ApiModel;
+
+ it('two operations that camel-case alike get distinct methods — PHP fatals on a redeclare', () => {
+ const out = phpGenerator({
+ model: colliding,
+ outputPath: '/out/client.ts',
+ outputMode: 'single',
+ emit: {},
+ })[0].content;
+ expect(out).toContain('public function getUser(string $id');
+ expect(out).toContain('public function getUser2(string $name');
+ });
+
+ it('the code sample names the deduped method, not the raw one', () => {
+ const sample = phpSample(colliding.services[0].operations[1], {
+ model: colliding,
+ outputPath: '/out/client.ts',
+ emit: {},
+ });
+ expect(sample?.source).toContain('$client->getUser2(');
+ });
+});
+
+describe('phpGenerator runtime: module', () => {
+ it('requires runtime.php beside the client and rewrites its namespace to match', () => {
+ const files = phpGenerator({
+ model: CAFE,
+ outputPath: '/out/client.ts',
+ outputMode: 'single',
+ emit: { runtime: 'module' },
+ });
+ const entry = files.find((file) => file.path === '/out/client.php')!.content;
+ expect(entry).toContain("require_once __DIR__ . '/runtime.php';");
+ expect(entry).not.toContain('// ─── Embedded runtime');
+ const runtime = files.find((file) => file.path === '/out/runtime.php')!.content;
+ expect(runtime).toContain('namespace CafeOrdersApi;');
+ expect(runtime).not.toContain('namespace RedoclyClientRuntime;');
+ expect(runtime).toContain('// Generated by @redocly/client-generator');
+ });
+});
+
describe('phpGenerator (full client assembly)', () => {
it('assembles one runnable file: namespace, models, embedded runtime, operations, Client', () => {
const out = generatePhp();
@@ -541,7 +649,8 @@ describe('phpGenerator (full client assembly)', () => {
headerParams: [],
cookieParams: [],
security: [],
- paginationExtension: { style: 'cursor', cursorParam: 'cursor', items: '' },
+ paginationExtension: { style: 'link', items: '' },
+ successResponseHeaders: [{ name: 'link', schema: STRING }],
successResponses: [
{
status: '200',
@@ -603,6 +712,55 @@ describe('phpGenerator (full client assembly)', () => {
expectModelsRun(out);
});
+ it('a bare date-time success body hydrates to the DateTimeImmutable its signature declares', () => {
+ // The top-level hydration call dropped `dateType`, so the method returned the raw
+ // string while its own return type said `\\DateTimeImmutable`.
+ const dated: ApiModel = {
+ title: 'Cafe',
+ version: '1.0.0',
+ serverUrl: 'https://api.cafe.example',
+ schemas: [],
+ securitySchemes: [],
+ services: [
+ {
+ name: 'Default',
+ operations: [
+ {
+ name: 'getDeadline',
+ specName: 'getDeadline',
+ method: 'get',
+ path: '/deadline',
+ tags: [],
+ pathParams: [],
+ queryParams: [],
+ headerParams: [],
+ cookieParams: [],
+ security: [],
+ successResponses: [
+ {
+ status: '200',
+ contentType: 'application/json',
+ schema: { kind: 'scalar', scalar: 'string', metadata: { format: 'date-time' } },
+ },
+ ],
+ errorResponses: [],
+ },
+ ],
+ },
+ ],
+ } as unknown as ApiModel;
+ const out = phpGenerator({
+ model: dated,
+ outputPath: '/out/client.ts',
+ outputMode: 'single',
+ emit: { dateType: 'Date' },
+ })[0].content;
+ expect(out).toContain(
+ 'public function getDeadline(?array $headers = null): \\DateTimeImmutable'
+ );
+ expect(out).toContain('new \\DateTimeImmutable(decodeJson($response))');
+ });
+
it('maps date/date-time to DateTimeImmutable under dateType: Date, hydrating both ways', () => {
const DATE_TIME: SchemaModel = {
kind: 'scalar',
diff --git a/packages/client-generator/src/generators/__tests__/python-runtime-embed.test.ts b/packages/client-generator/src/generators/__tests__/python-runtime-embed.test.ts
index 9dbd145175..13c8b458de 100644
--- a/packages/client-generator/src/generators/__tests__/python-runtime-embed.test.ts
+++ b/packages/client-generator/src/generators/__tests__/python-runtime-embed.test.ts
@@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
-import { PYTHON_RUNTIME_SOURCES } from '../../emitters/python-runtime-sources.js';
+import { PYTHON_RUNTIME_SOURCES } from '../../runtime-sources/python.js';
const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const hasPython = spawnSync('python3', ['--version']).status === 0;
@@ -21,7 +21,7 @@ describe('PYTHON_RUNTIME_SOURCES (the embedded Python runtime)', () => {
for (const name of Object.keys(PYTHON_RUNTIME_SOURCES)) {
const result = spawnSync(
'python3',
- ['-m', 'py_compile', join(pkgRoot, 'runtime', 'python', name)],
+ ['-m', 'py_compile', join(pkgRoot, 'src', 'generators', 'python', 'runtime', name)],
{
encoding: 'utf-8',
}
diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts
index 8f74b9036b..863d586180 100644
--- a/packages/client-generator/src/generators/__tests__/python.test.ts
+++ b/packages/client-generator/src/generators/__tests__/python.test.ts
@@ -4,7 +4,13 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js';
-import { pythonGenerator, renderPythonModels } from '../python/index.js';
+import { pythonGenerator as pythonGeneratorEntry, renderPythonModels } from '../python/index.js';
+import { generatorInput } from './fixtures/generator-input.js';
+
+// The pipeline parses the output anchor and resolves pagination once; `generatorInput`
+// mirrors those steps for these direct calls.
+const pythonGenerator = (input: Parameters[0]) =>
+ pythonGeneratorEntry(generatorInput(input));
const hasPython = spawnSync('python3', ['--version']).status === 0;
const hasHttpx = hasPython && spawnSync('python3', ['-c', 'import httpx']).status === 0;
@@ -400,6 +406,7 @@ const CAFE: ApiModel = {
schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } },
required: true,
},
+ { name: 'next', schema: STRING, required: false },
],
},
},
@@ -419,6 +426,25 @@ function generate(errorMode: 'throw' | 'result' = 'throw'): string {
return files[0].content;
}
+describe('pythonGenerator runtime: module', () => {
+ it('writes the runtime as sibling modules and star-imports them instead of embedding', () => {
+ const files = pythonGenerator({
+ model: CAFE,
+ outputPath: '/out/client.ts',
+ outputMode: 'single',
+ emit: { runtime: 'module' },
+ });
+ const entry = files.find((file) => file.path === '/out/client.py')!.content;
+ expect(entry).toContain('from _send import *');
+ expect(entry).not.toContain('# ─── Embedded runtime');
+ // The flat sibling layout has no package, so the intra-runtime imports drop the dot.
+ const send = files.find((file) => file.path === '/out/_send.py')!.content;
+ expect(send).toContain('from _errors import');
+ expect(send).not.toContain('from ._');
+ expect(send.startsWith('# Generated by @redocly/client-generator')).toBe(true);
+ });
+});
+
describe('python auth keys', () => {
it('accepts apiKey (the documented, cross-language key) and api_key alike', () => {
if (!hasHttpx) return;
@@ -551,7 +577,17 @@ describe('pythonGenerator parity features', () => {
{
status: '200',
contentType: 'application/json',
- schema: { kind: 'object', properties: [] },
+ schema: {
+ kind: 'object',
+ properties: [
+ {
+ name: 'items',
+ schema: { kind: 'array', items: { kind: 'object', properties: [] } },
+ required: true,
+ },
+ { name: 'next', schema: STRING, required: false },
+ ],
+ },
},
],
errorResponses: [],
@@ -594,6 +630,82 @@ describe('pythonGenerator parity features', () => {
expectCompiles(out);
});
+ it('iterator signatures annotate a date query param like the method does', () => {
+ // The `_pages`/`_items` wrappers dropped `dateType`, so `since` was `str` on the
+ // iterator while the method beside it said `datetime`.
+ const paged: ApiModel = {
+ title: 'Cafe',
+ version: '1.0.0',
+ serverUrl: 'https://api.cafe.example',
+ schemas: [],
+ securitySchemes: [],
+ services: [
+ {
+ name: 'Orders',
+ operations: [
+ {
+ name: 'listOrders',
+ specName: 'listOrders',
+ method: 'get',
+ path: '/orders',
+ tags: [],
+ pathParams: [],
+ queryParams: [
+ {
+ name: 'after',
+ in: 'query',
+ required: false,
+ schema: { kind: 'scalar', scalar: 'string' },
+ },
+ {
+ name: 'since',
+ in: 'query',
+ required: false,
+ schema: { kind: 'scalar', scalar: 'string', metadata: { format: 'date-time' } },
+ },
+ ],
+ headerParams: [],
+ cookieParams: [],
+ security: [],
+ paginationExtension: {
+ style: 'cursor',
+ cursorParam: 'after',
+ nextCursor: '/next',
+ items: '/items',
+ },
+ successResponses: [
+ {
+ status: '200',
+ contentType: 'application/json',
+ schema: {
+ kind: 'object',
+ properties: [
+ {
+ name: 'items',
+ schema: { kind: 'array', items: { kind: 'object', properties: [] } },
+ required: true,
+ },
+ { name: 'next', schema: STRING, required: false },
+ ],
+ },
+ },
+ ],
+ errorResponses: [],
+ },
+ ],
+ },
+ ],
+ } as unknown as ApiModel;
+ const out = pythonGenerator({
+ model: paged,
+ outputPath: '/out/client.ts',
+ outputMode: 'single',
+ emit: { dateType: 'Date' },
+ })[0].content;
+ expect(out).toMatch(/def list_orders_pages\([^)]*since: Optional\[datetime\]/);
+ expect(out).not.toMatch(/def list_orders_pages\([^)]*since: Optional\[str\]/);
+ });
+
it('maps date/date-time to datetime objects under dateType: Date, and round-trips them', () => {
const dated: ApiModel = {
title: 'Cafe',
diff --git a/packages/client-generator/src/generators/__tests__/resolve.test.ts b/packages/client-generator/src/generators/__tests__/resolve.test.ts
index 3312fd9429..66dc6f6ae5 100644
--- a/packages/client-generator/src/generators/__tests__/resolve.test.ts
+++ b/packages/client-generator/src/generators/__tests__/resolve.test.ts
@@ -33,6 +33,23 @@ describe('resolveGenerators', () => {
expect(registry.get('route-map')?.options).toEqual(custom.options);
});
+ it('keeps the docs and notApplicable hooks — an ejected generator exports both', async () => {
+ // Dropping either makes an ejected generator quietly do less than the built-in it
+ // replaced: `--docs` writes no page, ignored options stop warning.
+ const docs = noopRun;
+ const custom: CustomGenerator = {
+ name: 'route-map',
+ run: noopRun,
+ docs,
+ notApplicable: { importExt: 'it emits no imports' },
+ };
+ const { registry } = await resolveGenerators(['route-map'], { customGenerators: [custom] });
+ expect(registry.get('route-map')?.docs).toBe(docs);
+ expect(registry.get('route-map')?.notApplicable).toEqual({
+ importExt: 'it emits no imports',
+ });
+ });
+
it('registers an inline custom generator and selects it by name', async () => {
const custom: CustomGenerator = { name: 'route-map', run: noopRun };
const { selected, registry } = await resolveGenerators(['typescript', 'route-map'], {
diff --git a/packages/client-generator/src/generators/__tests__/runtime-embed-freshness.test.ts b/packages/client-generator/src/generators/__tests__/runtime-embed-freshness.test.ts
index 2966209819..7354cb3426 100644
--- a/packages/client-generator/src/generators/__tests__/runtime-embed-freshness.test.ts
+++ b/packages/client-generator/src/generators/__tests__/runtime-embed-freshness.test.ts
@@ -8,26 +8,26 @@ import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
-import { GO_RUNTIME_SOURCE } from '../../emitters/go-runtime-sources.js';
-import { PHP_RUNTIME_SOURCE } from '../../emitters/php-runtime-sources.js';
-import { PYTHON_RUNTIME_SOURCES } from '../../emitters/python-runtime-sources.js';
+import { GO_RUNTIME_SOURCE } from '../../runtime-sources/go.js';
+import { PHP_RUNTIME_SOURCE } from '../../runtime-sources/php.js';
+import { PYTHON_RUNTIME_SOURCES } from '../../runtime-sources/python.js';
const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const STALE = 'stale embed — run `npm run prepare -w @redocly/client-generator`';
describe('embedded runtimes match their source files', () => {
it('go', () => {
- const source = readFileSync(join(pkgRoot, 'runtime/go/runtime.go'), 'utf-8');
+ const source = readFileSync(join(pkgRoot, 'src/generators/go/runtime/runtime.go'), 'utf-8');
expect(GO_RUNTIME_SOURCE, STALE).toBe(source);
});
it('php', () => {
- const source = readFileSync(join(pkgRoot, 'runtime/php/runtime.php'), 'utf-8');
+ const source = readFileSync(join(pkgRoot, 'src/generators/php/runtime/runtime.php'), 'utf-8');
expect(PHP_RUNTIME_SOURCE, STALE).toBe(source);
});
it('python — every module, and no module missing from the snapshot', () => {
- const dir = join(pkgRoot, 'runtime', 'python');
+ const dir = join(pkgRoot, 'src', 'generators', 'python', 'runtime');
const onDisk = readdirSync(dir).filter((name) => name.endsWith('.py'));
expect(Object.keys(PYTHON_RUNTIME_SOURCES).sort(), STALE).toEqual(onDisk.sort());
const embedded: Record = PYTHON_RUNTIME_SOURCES;
diff --git a/packages/client-generator/src/generators/__tests__/swr.test.ts b/packages/client-generator/src/generators/__tests__/swr.test.ts
index 818893d1b0..08ba50429c 100644
--- a/packages/client-generator/src/generators/__tests__/swr.test.ts
+++ b/packages/client-generator/src/generators/__tests__/swr.test.ts
@@ -1,6 +1,11 @@
-import { apiModel, operation } from '../../emitters/__tests__/fixtures.js';
+import { apiModel, operation } from '../../__tests__/fixtures.js';
import { builtinGenerators } from '../index.js';
-import { swrGenerator } from '../swr/index.js';
+import { swrGenerator as swrGeneratorEntry } from '../swr/index.js';
+import { generatorInput } from './fixtures/generator-input.js';
+
+// The pipeline parses the output anchor; `generatorInput` mirrors it for direct calls.
+const swrGenerator = (input: Parameters[0]) =>
+ swrGeneratorEntry(generatorInput(input));
const SERVICES = [
{
@@ -41,6 +46,6 @@ describe('swrGenerator', () => {
});
it('is registered under "swr"', () => {
- expect(builtinGenerators().get('swr')?.run).toBe(swrGenerator);
+ expect(builtinGenerators().get('swr')?.run).toBe(swrGeneratorEntry);
});
});
diff --git a/packages/client-generator/src/generators/__tests__/tanstack-query.test.ts b/packages/client-generator/src/generators/__tests__/tanstack-query.test.ts
index 7e188b6e2a..b633efaadd 100644
--- a/packages/client-generator/src/generators/__tests__/tanstack-query.test.ts
+++ b/packages/client-generator/src/generators/__tests__/tanstack-query.test.ts
@@ -1,6 +1,13 @@
-import { apiModel, operation } from '../../emitters/__tests__/fixtures.js';
+import { apiModel, operation } from '../../__tests__/fixtures.js';
import { builtinGenerators } from '../index.js';
-import { tanstackQueryGenerator } from '../tanstack-query/index.js';
+import { tanstackQueryGenerator as tanstackQueryGeneratorEntry } from '../tanstack-query/index.js';
+import { generatorInput } from './fixtures/generator-input.js';
+
+// The pipeline parses the output anchor; `generatorInput` mirrors it for direct calls.
+const tanstackQueryGenerator =
+ (framework: Parameters[0]) =>
+ (input: Parameters[0]) =>
+ tanstackQueryGeneratorEntry(framework)(generatorInput(input));
const SERVICES = [
{
@@ -51,12 +58,12 @@ describe('tanstackQueryGenerator', () => {
it('binds the framework per registry name — bare tanstack-query stays React', () => {
const registry = builtinGenerators();
- const input = {
+ const input = generatorInput({
model: apiModel({ services: SERVICES }),
outputPath: '/tmp/out/client.ts',
outputMode: 'single' as const,
emit: {},
- };
+ });
const importOf = (name: string) =>
registry
.get(name)!
diff --git a/packages/client-generator/src/generators/__tests__/transformers.test.ts b/packages/client-generator/src/generators/__tests__/transformers.test.ts
index 52b8c2d3c0..d697d41ed8 100644
--- a/packages/client-generator/src/generators/__tests__/transformers.test.ts
+++ b/packages/client-generator/src/generators/__tests__/transformers.test.ts
@@ -1,6 +1,11 @@
-import { apiModel, namedSchema } from '../../emitters/__tests__/fixtures.js';
+import { apiModel, namedSchema } from '../../__tests__/fixtures.js';
import { builtinGenerators } from '../index.js';
-import { transformersGenerator } from '../transformers/index.js';
+import { transformersGenerator as transformersGeneratorEntry } from '../transformers/index.js';
+import { generatorInput } from './fixtures/generator-input.js';
+
+// The pipeline parses the output anchor; `generatorInput` mirrors it for direct calls.
+const transformersGenerator = (input: Parameters[0]) =>
+ transformersGeneratorEntry(generatorInput(input));
const EVENT = namedSchema('Event', {
kind: 'object',
@@ -74,6 +79,6 @@ describe('transformersGenerator', () => {
});
it('is registered under "transformers"', () => {
- expect(builtinGenerators().get('transformers')?.run).toBe(transformersGenerator);
+ expect(builtinGenerators().get('transformers')?.run).toBe(transformersGeneratorEntry);
});
});
diff --git a/packages/client-generator/src/generators/__tests__/typescript.test.ts b/packages/client-generator/src/generators/__tests__/typescript.test.ts
index db6fb923e0..c72194dbe5 100644
--- a/packages/client-generator/src/generators/__tests__/typescript.test.ts
+++ b/packages/client-generator/src/generators/__tests__/typescript.test.ts
@@ -1,6 +1,11 @@
-import { HEADER } from '../../emitters/emit-options.js';
import type { ApiModel } from '../../intermediate-representation/model.js';
-import { typescriptGenerator } from '../typescript/index.js';
+import { HEADER } from '../typescript/banner.js';
+import { typescriptGenerator as typescriptGeneratorEntry } from '../typescript/index.js';
+import { generatorInput } from './fixtures/generator-input.js';
+
+// The pipeline parses the output anchor; `generatorInput` mirrors it for direct calls.
+const typescriptGenerator = (input: Parameters[0]) =>
+ typescriptGeneratorEntry(generatorInput(input));
function apiModel(): ApiModel {
return {
diff --git a/packages/client-generator/src/generators/__tests__/zod.test.ts b/packages/client-generator/src/generators/__tests__/zod.test.ts
index d6029cee6a..9c41775493 100644
--- a/packages/client-generator/src/generators/__tests__/zod.test.ts
+++ b/packages/client-generator/src/generators/__tests__/zod.test.ts
@@ -1,5 +1,10 @@
-import { apiModel, namedSchema } from '../../emitters/__tests__/fixtures.js';
-import { zodGenerator } from '../zod/index.js';
+import { apiModel, namedSchema } from '../../__tests__/fixtures.js';
+import { zodGenerator as zodGeneratorEntry } from '../zod/index.js';
+import { generatorInput } from './fixtures/generator-input.js';
+
+// The pipeline parses the output anchor; `generatorInput` mirrors it for direct calls.
+const zodGenerator = (input: Parameters[0]) =>
+ zodGeneratorEntry(generatorInput(input));
const PET = namedSchema('Pet', {
kind: 'object',
diff --git a/packages/client-generator/src/generators/anchor.ts b/packages/client-generator/src/generators/anchor.ts
deleted file mode 100644
index ca629642b6..0000000000
--- a/packages/client-generator/src/generators/anchor.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { parse } from 'node:path';
-
-/**
- * Derive the directory and base name (stem, without `.ts`) from the `--output`
- * anchor path. Generators build sibling-file paths from these.
- */
-export function anchor(outputPath: string): { dir: string; stem: string } {
- const { dir, name } = parse(outputPath);
- return { dir, stem: name };
-}
diff --git a/packages/client-generator/src/emitters/__tests__/cli.test.ts b/packages/client-generator/src/generators/cli/__tests__/render.test.ts
similarity index 86%
rename from packages/client-generator/src/emitters/__tests__/cli.test.ts
rename to packages/client-generator/src/generators/cli/__tests__/render.test.ts
index 2c390ba08d..3155330ee9 100644
--- a/packages/client-generator/src/emitters/__tests__/cli.test.ts
+++ b/packages/client-generator/src/generators/cli/__tests__/render.test.ts
@@ -1,7 +1,8 @@
import { logger } from '@redocly/openapi-core';
-import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js';
-import { commandData, renderCliModule, renderComposedCliEntry } from '../cli.js';
+import type { ApiModel, SchemaModel } from '../../../intermediate-representation/model.js';
+import { resolveModelPagination } from '../../../pagination.js';
+import { commandData, renderCliModule, renderComposedCliEntry } from '../render.js';
const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' };
const INT: SchemaModel = { kind: 'scalar', scalar: 'integer' };
@@ -119,6 +120,7 @@ const MODEL: ApiModel = {
schema: { kind: 'object', properties: [] },
},
],
+ sse: { eventSchema: { kind: 'object', properties: [] }, dataKind: 'json' },
errorResponses: [],
},
{
@@ -184,7 +186,7 @@ const MODEL: ApiModel = {
describe('commandData', () => {
it('derives groups from tags, flags from query params, and positionals in path order', () => {
- const commands = commandData(MODEL, {});
+ const commands = commandData(MODEL, { pagination: resolveModelPagination(MODEL, undefined) });
const list = commands.find((command) => command.name === 'listOrders');
expect(list).toMatchObject({
group: 'Orders',
@@ -222,10 +224,18 @@ describe('renderCliModule', () => {
const options = {
stem: 'client',
importExt: 'js',
- runtime: 'inline' as const,
zodSelected: false,
};
+ it('runtime: module imports the engine from ./runtime/cli instead of embedding it', () => {
+ const out = renderCliModule(MODEL, { ...options, runtime: 'module' });
+ expect(out).toContain(
+ 'import { invokedName, runCli, type CliCommand, type CliWiring } from "./runtime/cli.js";'
+ );
+ expect(out).not.toContain('function parseInvocation'); // no embedded engine
+ expect(out).toContain('export { runCli };'); // composition contract holds in both modes
+ });
+
it('emits a shebang entry that wires node bindings and embeds the cli runtime inline', () => {
const out = renderCliModule(MODEL, options);
expect(out.startsWith('#!/usr/bin/env node')).toBe(true);
@@ -243,12 +253,8 @@ describe('renderCliModule', () => {
expect(out).not.toContain('from "@redocly/client-generator"');
});
- it('package mode imports runCli from the package; zod co-selection wires validation', () => {
- const out = renderCliModule(MODEL, { ...options, runtime: 'package', zodSelected: true });
- expect(out).toContain(
- 'import { invokedName, runCli, type CliCommand, type CliWiring } from "@redocly/client-generator";'
- );
- expect(out).not.toContain('function parseInvocation');
+ it('zod co-selection wires validation', () => {
+ const out = renderCliModule(MODEL, { ...options, zodSelected: true });
expect(out).toContain('import { zodValidation } from "./client.zod.js";');
expect(out).toContain(
'use(zodValidation(process.argv.includes("--dry-run") ? { response: false } : {}));'
@@ -286,32 +292,6 @@ describe('renderCliModule', () => {
});
});
-describe('the package-mode import line', () => {
- it('names only values the package root exports', async () => {
- // The emitted entry is the only consumer of these names, and a missing export breaks
- // every package-mode CLI at import time rather than at generation.
- const out = renderCliModule(MODEL, {
- stem: 'client',
- importExt: 'js',
- runtime: 'package',
- zodSelected: false,
- });
- const line = out
- .split('\n')
- .find((candidate) => candidate.includes('from "@redocly/client-generator"'));
- expect(line, 'no package import line found').toBeDefined();
- const names = line!
- .slice(line!.indexOf('{') + 1, line!.indexOf('}'))
- .split(',')
- .map((specifier) => specifier.trim())
- .filter((specifier) => specifier !== '' && !specifier.startsWith('type '));
- const root = (await import('../../index.js')) as Record;
- for (const name of names) {
- expect(typeof root[name], `${name} is imported but not exported`).toBe('function');
- }
- });
-});
-
describe('renderComposedCliEntry', () => {
it('keeps import bindings legal for digit-leading aliases and unique for colliding ones', () => {
const out = renderComposedCliEntry(
diff --git a/packages/client-generator/src/emitters/cli-docs.ts b/packages/client-generator/src/generators/cli/docs.ts
similarity index 98%
rename from packages/client-generator/src/emitters/cli-docs.ts
rename to packages/client-generator/src/generators/cli/docs.ts
index 5fbe7f8850..4dcfaba7ea 100644
--- a/packages/client-generator/src/emitters/cli-docs.ts
+++ b/packages/client-generator/src/generators/cli/docs.ts
@@ -3,8 +3,13 @@
// runtime addresses groups and reads credentials with. A second model would drift from
// the tool the first time either side changed.
-import { Printer } from '../authoring/printer.js';
-import { constantCase, groupSlug, type CliCommand, type CliFlag } from '../runtime/cli.js';
+import {
+ type CliCommand,
+ type CliFlag,
+ constantCase,
+ groupSlug,
+ Printer,
+} from '@redocly/client-generator';
export type CliDocsOptions = {
/** Page heading. */
diff --git a/packages/client-generator/src/generators/cli/engine-source.ts b/packages/client-generator/src/generators/cli/engine-source.ts
new file mode 100644
index 0000000000..982d5035fd
--- /dev/null
+++ b/packages/client-generator/src/generators/cli/engine-source.ts
@@ -0,0 +1,17 @@
+// The cli engine's embeddable source, snapshotted at prepare time (see
+// scripts/generate-runtime-sources.mjs).
+
+import {
+ RUNTIME_SOURCES,
+ RUNTIME_SOURCES_STRIPPED,
+} from '@redocly/client-generator/runtime-sources';
+
+/** The cli engine (`runCli` + types) stripped for embedding into `.cli.ts`. */
+export function embedCliRuntime(): string {
+ return RUNTIME_SOURCES_STRIPPED['cli.ts'];
+}
+
+/** The cli engine RAW, for `runtime: 'module'` (written as `runtime/cli.ts`). */
+export function cliRuntimeSource(): string {
+ return RUNTIME_SOURCES['cli.ts'];
+}
diff --git a/packages/client-generator/src/generators/cli/index.ts b/packages/client-generator/src/generators/cli/index.ts
index 2f63003d0a..2049c06da9 100644
--- a/packages/client-generator/src/generators/cli/index.ts
+++ b/packages/client-generator/src/generators/cli/index.ts
@@ -1,11 +1,15 @@
+import {
+ type CodeSample,
+ type Generator,
+ groupSlug,
+ type OperationModel,
+ type SampleContext,
+} from '@redocly/client-generator';
import { join } from 'node:path';
-import { renderCliDocs } from '../../emitters/cli-docs.js';
-import { cliAuthSchemes, commandData, renderCliModule } from '../../emitters/cli.js';
-import type { OperationModel } from '../../intermediate-representation/model.js';
-import { groupSlug } from '../../runtime/cli.js';
-import { anchor } from '../anchor.js';
-import type { CodeSample, Generator, SampleContext } from '../types.js';
+import { renderCliDocs } from './docs.ts';
+import { cliRuntimeSource } from './engine-source.ts';
+import { cliAuthSchemes, commandData, renderCliModule } from './render.ts';
/**
* The cli generator: a bin-ready `.cli.ts` — a zero-dependency, typed
@@ -13,17 +17,25 @@ import type { CodeSample, Generator, SampleContext } from '../types.js';
* bodies, env auth, `--page-all`, SSE/blob output, a documented exit-code
* contract). Requires `typescript` (throw mode); wires zod validation when co-selected.
*/
-export const cliGenerator: Generator = ({ model, outputPath, emit, selected }) => {
- const { dir, stem } = anchor(outputPath);
+export const cliGenerator: Generator = ({ model, output, banner, emit, selected, pagination }) => {
const content = renderCliModule(model, {
- stem,
+ stem: output.stem,
importExt: emit.importExt ?? 'js',
- runtime: emit.runtime ?? 'inline',
zodSelected: selected?.includes('zod') ?? false,
- pagination: emit.pagination,
+ pagination,
argsStyle: emit.argsStyle ?? 'grouped',
+ runtime: emit.runtime ?? 'inline',
});
- return [{ path: join(dir, `${stem}.cli.ts`), content }];
+ const entry = { path: join(output.dir, `${output.stem}.cli.ts`), content };
+ if (emit.runtime !== 'module') return [entry];
+ const header = banner.map((line) => `// ${line}`).join('\n');
+ return [
+ entry,
+ {
+ path: join(output.dir, 'runtime', 'cli.ts'),
+ content: `${header}\n\n${cliRuntimeSource().trim()}\n`,
+ },
+ ];
};
/**
@@ -32,20 +44,19 @@ export const cliGenerator: Generator = ({ model, outputPath, emit, selected }) =
* It renders from `commandData` — the same table `runCli` dispatches on — so the page
* cannot describe a tool other than the one beside it.
*/
-export const cliDocs: Generator = ({ model, outputPath, emit }) => {
- const { dir, stem } = anchor(outputPath);
- const content = renderCliDocs(commandData(model, { pagination: emit.pagination }), {
+export const cliDocs: Generator = ({ model, output, emit, pagination }) => {
+ const content = renderCliDocs(commandData(model, { pagination }), {
title: `${model.title} command-line reference`,
frontmatter: emit.docsFrontmatter === true,
- name: stem,
+ name: output.stem,
schemes: cliAuthSchemes(model),
});
- return [{ path: join(dir, `${stem}.cli.md`), content }];
+ return [{ path: join(output.dir, `${output.stem}.cli.md`), content }];
};
/** One shell invocation per operation — feeds `x-codeSamples` for docs. */
export function cliSample(op: OperationModel, ctx: SampleContext): CodeSample | undefined {
- const command = commandData(ctx.model, { pagination: ctx.emit.pagination }).find(
+ const command = commandData(ctx.model, { pagination: ctx.pagination }).find(
(candidate) => candidate.name === op.name
);
if (command === undefined) return undefined;
diff --git a/packages/client-generator/src/emitters/cli.ts b/packages/client-generator/src/generators/cli/render.ts
similarity index 90%
rename from packages/client-generator/src/emitters/cli.ts
rename to packages/client-generator/src/generators/cli/render.ts
index dd34734e7f..b755238ebc 100644
--- a/packages/client-generator/src/emitters/cli.ts
+++ b/packages/client-generator/src/generators/cli/render.ts
@@ -2,27 +2,28 @@
// `.cli.ts` — a shebang entry that embeds (inline) or imports (package)
// the `runCli` engine and dispatches through the sibling generated client.
-import { logger } from '@redocly/openapi-core';
-
-import { casing } from '../authoring/naming.js';
-import type {
- ApiModel,
- OperationModel,
- ParamModel,
- SchemaModel,
-} from '../intermediate-representation/model.js';
import {
- constantCase,
- groupSlug,
+ type ApiModel,
+ casing,
type CliAuthScheme,
type CliCommand,
type CliFlag,
-} from '../runtime/cli.js';
-import { HEADER } from './emit-options.js';
-import { embedCliRuntime } from './inline-runtime.js';
-import { resolveOperationPagination, type PaginationConfig } from './pagination.js';
-import { flatInputShape } from './render-client.js';
-import { isSseOp } from './sse.js';
+ constantCase,
+ groupSlug,
+ type ModelPagination,
+ type OperationModel,
+ type ParamModel,
+ type SchemaModel,
+} from '@redocly/client-generator';
+import { flatInputShape } from '@redocly/client-generator/contracts/typescript';
+import { logger } from '@redocly/openapi-core';
+
+import { embedCliRuntime } from './engine-source.ts';
+
+// The generated-by banner every emitted module carries (same lines as the pipeline's
+// `input.banner`, rendered in `//` syntax).
+const HEADER = `// Generated by @redocly/client-generator — do not edit by hand.
+// Source: OpenAPI description. Re-run \`redocly generate-client\` to update.`;
function kebab(name: string): string {
return casing.snake(name).replace(/_/g, '-');
@@ -97,7 +98,7 @@ function groupedInputFlag(
/** Every operation as pure command data — the table `runCli` interprets. */
export function commandData(
model: ApiModel,
- emit: { pagination?: PaginationConfig; argsStyle?: 'grouped' | 'flat' }
+ emit: { pagination?: ModelPagination; argsStyle?: 'grouped' | 'flat' }
): CliCommand[] {
const commands: CliCommand[] = [];
for (const service of model.services) {
@@ -124,11 +125,9 @@ export function commandData(
...(jsonBody === undefined && op.requestBody !== undefined
? { unsupportedBody: op.requestBody.contentType }
: {}),
- ...(resolveOperationPagination(op, model, emit.pagination).spec !== undefined
- ? { paginated: true }
- : {}),
+ ...(emit.pagination?.has(op.name) === true ? { paginated: true } : {}),
...groupedInputFlag(op, model, emit.argsStyle),
- ...(isSseOp(op) ? { sse: true } : {}),
+ ...(op.sse !== undefined ? { sse: true } : {}),
...(isBlobOp(op) ? { blob: true } : {}),
...(jsonBody !== undefined || responseSchema !== undefined
? {
@@ -171,11 +170,12 @@ function codeJson(value: unknown, indent?: number): string {
export type CliModuleOptions = {
stem: string;
importExt: string;
- runtime: 'inline' | 'package';
zodSelected: boolean;
- pagination?: PaginationConfig;
+ pagination?: ModelPagination;
/** The sibling client's call shape, which the dispatcher builds its inputs for. */
argsStyle?: 'grouped' | 'flat';
+ /** `'module'` imports the engine from `./runtime/cli` instead of embedding it. */
+ runtime?: 'inline' | 'module';
};
/**
@@ -231,19 +231,14 @@ export function renderCliModule(model: ApiModel, options: CliModuleOptions): str
HEADER,
'import { readFileSync, realpathSync, writeFileSync } from "node:fs";\nimport { fileURLToPath } from "node:url";',
[
- ...(options.runtime === 'package'
- ? [
- 'import { invokedName, runCli, type CliCommand, type CliWiring } from "@redocly/client-generator";',
- ]
- : []),
`import { ${clientImports.join(', ')} } from "${clientModule}";`,
...(options.zodSelected
? [`import { zodValidation } from "./${options.stem}.zod.${options.importExt}";`]
: []),
].join('\n'),
- ...(options.runtime === 'inline'
- ? ['// ─── Embedded cli engine (@redocly/client-generator) ───\n' + embedCliRuntime()]
- : []),
+ options.runtime === 'module'
+ ? `import { invokedName, runCli, type CliCommand, type CliWiring } from "./runtime/cli.${options.importExt}";`
+ : '// ─── Embedded cli engine (@redocly/client-generator) ───\n' + embedCliRuntime(),
`export const COMMANDS: CliCommand[] = ${codeJson(commands, 2)};`,
...(options.zodSelected
? [
@@ -340,6 +335,9 @@ ${entries.join('\n')}
export const run = (argv: string[] = process.argv.slice(2)): Promise =>
runCli(SOURCES, argv);
+// Re-exported so a wrapper (a custom \`login\` command) can run these sources itself.
+export { runCli };
+
${ENTRY_GUARD}`,
].join('\n\n') + '\n'
);
diff --git a/packages/client-generator/src/runtime/__tests__/cli.test.ts b/packages/client-generator/src/generators/cli/runtime/__tests__/cli.test.ts
similarity index 100%
rename from packages/client-generator/src/runtime/__tests__/cli.test.ts
rename to packages/client-generator/src/generators/cli/runtime/__tests__/cli.test.ts
diff --git a/packages/client-generator/src/runtime/cli.ts b/packages/client-generator/src/generators/cli/runtime/cli.ts
similarity index 84%
rename from packages/client-generator/src/runtime/cli.ts
rename to packages/client-generator/src/generators/cli/runtime/cli.ts
index 575d842a22..0c12f2dc62 100644
--- a/packages/client-generator/src/runtime/cli.ts
+++ b/packages/client-generator/src/generators/cli/runtime/cli.ts
@@ -5,89 +5,28 @@
// the module itself stays dependency-free and fully unit-testable; the emitted
// entry fills the defaults with real `node:fs`/`process` bindings.
-/** One flag derived from a query parameter. */
-export type CliFlag = {
- /** Kebab-cased flag name (`--page-size`). */
- name: string;
- /** Original wire parameter name. */
- param: string;
- type: 'string' | 'number' | 'boolean' | 'array';
- required: boolean;
- enum?: string[];
- description?: string;
-};
+import {
+ constantCase,
+ groupSlug,
+ type CliAuthScheme,
+ type CliCommand,
+ type CliFlag,
+ type CliGlobals,
+ type CliWiring,
+ type CommandContext,
+ type CommandSource,
+ type CustomCommand,
+} from '../../../cli-contract.js';
-/** One executable command, derived from the IR at generate time. Pure data. */
-export type CliCommand = {
- /** Tag; absent = flat/untagged. */
- group?: string;
- name: string;
- summary?: string;
- method: string;
- path: string;
- /** Path params, in path-template order. Always required — that is what a path is. */
- positionals: Array<{
- name: string;
- type?: CliFlag['type'];
- description?: string;
- }>;
- flags: CliFlag[];
- /**
- * Present when the operation takes a JSON request body. `merged` marks a body whose own
- * properties a flat-style call spells at the top level (the generator decides this from
- * the schema, so the CLI and the client can never disagree).
- */
- body?: { required: boolean; merged?: boolean };
- /**
- * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).
- * `--json` cannot build one, so the command is reported as library-only rather than
- * offered as if it were runnable.
- */
- unsupportedBody?: string;
- paginated?: boolean;
- /** `'grouped'` marks a command whose client method takes namespaced inputs even on a
- * flat-style client, because its merged names would collide. */
- argsStyle?: 'grouped';
- sse?: boolean;
- blob?: boolean;
- /** IR schemas for the `schema` command, serialized verbatim. */
- schemas?: { request?: unknown; response?: unknown };
-};
+export type { CliFlag } from '../../../cli-contract.js';
-export type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };
-
-export type CliWiring = {
- /** The name the CLI is invoked as, for help output only. The generated entry reads it
- * from `process.argv[1]`, so help never names a command that is not installed. */
- name: string;
- /** Credential variable prefix, constant-cased: `CAFE` gives `CAFE_TOKEN`. Fixed at
- * generation from the output file name, so renaming the binary keeps the variables
- * a published CLI already documents. A composed entry sets one per api alias. */
- envPrefix: string;
- /** The generated instance client. */
- client: Record;
- /** How that client takes its inputs. Defaults to `'grouped'`, the generated default. */
- argsStyle?: 'grouped' | 'flat';
- configure: (config: Record) => void;
- /** Security schemes of the API — drives env-var credential resolution. */
- schemes?: CliAuthScheme[];
- env?: Record;
- stdin?: () => string;
- readFile?: (path: string) => string;
- writeFile?: (path: string, data: Uint8Array) => void;
- stdout: (line: string) => void;
- stderr: (line: string) => void;
-};
+export type { CliCommand } from '../../../cli-contract.js';
-export type CliGlobals = {
- serverUrl?: string;
- format?: 'json' | 'ndjson';
- dryRun?: boolean;
- pageAll?: boolean;
- output?: string;
- token?: string;
- json?: string;
-};
+export type { CliAuthScheme } from '../../../cli-contract.js';
+
+export type { CliWiring } from '../../../cli-contract.js';
+
+export type { CliGlobals } from '../../../cli-contract.js';
export type CliInvocation =
| { kind: 'help'; topic?: CliCommand | string }
@@ -101,37 +40,11 @@ export type CliInvocation =
}
| { kind: 'usage-error'; message: string };
-/**
- * A hand-written command composed NEXT TO the generated ones: the same data shape plus a
- * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is
- * how behavior that is not in the description (a `login`, a doctor command) joins the
- * binary without the generator ever learning what it does.
- */
-export type CustomCommand = {
- name: string;
- group?: string;
- summary?: string;
- positionals?: CliCommand['positionals'];
- flags?: CliFlag[];
- /** Returns the process exit code; throwing exits 1 with the standard error JSON. */
- handler: (context: CommandContext) => number | Promise;
-};
+export type { CustomCommand } from '../../../cli-contract.js';
-export type CommandContext = {
- positionals: Record;
- params: Record;
- globals: CliGlobals;
- wiring: CliWiring;
-};
+export type { CommandContext } from '../../../cli-contract.js';
-/** One API's contribution to a composed binary: its commands behind a namespace, with its
- * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */
-export type CommandSource = {
- namespace?: string;
- commands: Array;
- /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */
- wiring?: CliWiring;
-};
+export type { CommandSource } from '../../../cli-contract.js';
type ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };
@@ -187,18 +100,7 @@ export function invokedName(scriptPath: string | undefined, fallback: string): s
return name === '' ? fallback : name;
}
-/**
- * The shell-typable form of a group name: an OpenAPI tag can contain spaces ("Some
- * multi-word tag"), which only resolves if the user quotes it. Commands are addressed by
- * this slug; help still shows the original tag.
- */
-export function groupSlug(group: string): string {
- return group
- .toLowerCase()
- .split(/[^a-z0-9]+/)
- .filter(Boolean)
- .join('-');
-}
+export { groupSlug } from '../../../cli-contract.js';
/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */
function oneLine(text: string): string {
@@ -370,13 +272,7 @@ export function parseInvocation(commands: CliCommand[], argv: string[]): CliInvo
return { kind: 'run', command, positionals, params, globals };
}
-/** `cafe-api` → `CAFE_API`: the casing of every credential variable this CLI reads. */
-export function constantCase(value: string): string {
- return value
- .replace(/[^A-Za-z0-9]+/g, '_')
- .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
- .toUpperCase();
-}
+export { constantCase } from '../../../cli-contract.js';
function resolveAuth(wiring: CliWiring, token: string | undefined): Record {
const env = wiring.env ?? {};
diff --git a/packages/client-generator/src/generators/go/AGENTS.md b/packages/client-generator/src/generators/go/AGENTS.md
index a3b20e5784..6cdf8a4f8f 100644
--- a/packages/client-generator/src/generators/go/AGENTS.md
+++ b/packages/client-generator/src/generators/go/AGENTS.md
@@ -71,8 +71,10 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies.
inside a doc comment is `//` — never `// ` with a trailing space.
A change here is verified by the `gofmt -l` bar in the unit suite, at cafe AND
large-description scale.
-- The runtime is hand-written in `runtime/go/runtime.go` (gofmt-clean, `go vet`-clean)
+- The runtime is hand-written in `runtime/runtime.go` in this folder (gofmt-clean, `go vet`-clean)
and embedded at prepare time.
+ Under `--runtime module` it is written as a same-package `runtime.go` beside the client,
+ whose import block then lists only the packages its own body uses.
- Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise.
- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes
@@ -86,7 +88,7 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies.
## The modify loop
1. Edit this skill: state the new behavior or decision.
-2. Change `index.ts` (and `runtime/go/runtime.go` for runtime behavior; `gofmt -w` +
+2. Change `index.ts` (and `runtime/runtime.go` for runtime behavior; `gofmt -w` +
`go vet ./...` it, then `npm run prepare -w @redocly/client-generator`).
3. Verify: `npm run compile`, then
`VITEST_SUITE=unit npx vitest run packages/client-generator/src/generators/__tests__/go.test.ts`
diff --git a/packages/client-generator/src/generators/go/client.ts b/packages/client-generator/src/generators/go/client.ts
new file mode 100644
index 0000000000..e9c125238e
--- /dev/null
+++ b/packages/client-generator/src/generators/go/client.ts
@@ -0,0 +1,53 @@
+// The `client` stage: one `URL` function per declared server.
+
+import {
+ type ApiModel,
+ identifierFor,
+ type ServerModel,
+ serverUrlParts,
+} from '@redocly/client-generator';
+import { exported, type GoPrinter } from '@redocly/client-generator/printers/go';
+
+import { GO, naming } from './naming.ts';
+
+/** The server URL as a Go expression: literals concatenated with declared-variable args. */
+function serverUrlExpression(server: ServerModel): string {
+ const parts = serverUrlParts(server).map((part) =>
+ part.kind === 'literal'
+ ? naming.string(part.value)
+ : identifierFor(part.name, { style: 'camel', reserved: GO })
+ );
+ return parts.join(' + ');
+}
+
+/** One `URL` function per declared server; server variables become parameters. */
+export function writeGoServers(printer: GoPrinter, model: ApiModel): void {
+ const servers = model.servers ?? [];
+ if (servers.length === 0) return;
+ const usedNames = new Set();
+ servers.forEach((server, index) => {
+ let name = `${exported(server.description ?? `server${index + 1}`)}URL`;
+ if (usedNames.has(name)) name = `${name}${index + 1}`;
+ usedNames.add(name);
+ const params = server.variables.map(
+ (variable) => `${identifierFor(variable.name, { style: 'camel', reserved: GO })} string`
+ );
+ const defaults = server.variables
+ .map(
+ (variable) =>
+ `${identifierFor(variable.name, { style: 'camel', reserved: GO })} default: ${naming.string(variable.default)}`
+ )
+ .join(', ');
+ printer.line(
+ `// ${name} returns the ${naming.string(server.description ?? server.url)} base URL${defaults === '' ? '.' : ` (${defaults}).`}`
+ );
+ printer.block(
+ `func ${name}(${params.join(', ')}) string {`,
+ () => {
+ printer.line(`return ${serverUrlExpression(server)}`);
+ },
+ '}'
+ );
+ printer.blank();
+ });
+}
diff --git a/packages/client-generator/src/generators/go/descriptor.ts b/packages/client-generator/src/generators/go/descriptor.ts
new file mode 100644
index 0000000000..74695e43d0
--- /dev/null
+++ b/packages/client-generator/src/generators/go/descriptor.ts
@@ -0,0 +1,37 @@
+// The `descriptor` stage: the operations-table composite literals — security
+// OR-alternatives and the pagination spec.
+
+import {
+ type ApiModel,
+ type NeutralPaginationRule,
+ type OperationModel,
+ securityRequirements,
+} from '@redocly/client-generator';
+
+import { naming } from './naming.ts';
+
+/** Go composite literal for one operation's security OR-alternatives. */
+export function goSecurityLiteral(op: OperationModel, model: ApiModel): string | undefined {
+ const alternatives = securityRequirements(op, model).map((alternative) =>
+ alternative.map((spec) =>
+ spec.kind === 'apiKey'
+ ? `{Scheme: ${naming.string(spec.scheme)}, Kind: "apiKey", Name: ${naming.string(spec.name)}, In: ${naming.string(spec.in)}}`
+ : `{Scheme: ${naming.string(spec.scheme)}, Kind: ${naming.string(spec.kind)}}`
+ )
+ );
+ if (alternatives.length === 0) return undefined;
+ return `[][]SecuritySpec{${alternatives.map((specs) => `{${specs.join(', ')}}`).join(', ')}}`;
+}
+
+/** The neutral rule as a `&PaginationSpec{…}` composite literal for the operations table. */
+export function goPaginationLiteral(rule: NeutralPaginationRule): string {
+ const fields = [
+ `Style: ${naming.string(rule.style)}`,
+ ...(rule.param !== undefined ? [`Param: ${naming.string(rule.param)}`] : []),
+ ...(rule.nextCursor !== undefined ? [`NextCursor: ${naming.string(rule.nextCursor)}`] : []),
+ ...(rule.hasMore !== undefined ? [`HasMore: ${naming.string(rule.hasMore)}`] : []),
+ ...(rule.limitParam !== undefined ? [`LimitParam: ${naming.string(rule.limitParam)}`] : []),
+ ...(rule.items !== undefined ? [`Items: ${naming.string(rule.items)}`] : []),
+ ];
+ return `&PaginationSpec{${fields.join(', ')}}`;
+}
diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts
index c7653ec073..d56a545c57 100644
--- a/packages/client-generator/src/generators/go/index.ts
+++ b/packages/client-generator/src/generators/go/index.ts
@@ -3,431 +3,36 @@
// the python generator, pinned by its guard test). Output is a single
// stdlib-only Go file: structs with json tags, typed-const enums, discriminated
// unions with unmarshal dispatchers, and a Client over the embedded runtime.
+// One file per pipeline stage (ADR-0020); this entry assembles them.
import {
- casing,
- Printer,
- discriminatorCases,
- docText,
- enumValues,
- flattenAllOf,
- headerCoerceType,
- identifierFor,
- uniqueIdentifiers,
- isNullable,
- NotSupportedError,
- paginationRuleFor,
- renderReferencePage,
- RESERVED_WORDS,
- schemaAtPointer,
- unwrapNullable,
+ type ApiModel,
+ type CodeSample,
type DateType,
+ type EmitOptions,
+ type Generator,
+ identifierFor,
+ jsonSuccessSchema,
type NeutralPaginationRule,
-} from '../../authoring/index.js';
-import { GO_RUNTIME_SOURCE } from '../../emitters/go-runtime-sources.js';
-import type {
- ApiModel,
- OperationModel,
- ParamModel,
- PropertyModel,
- SchemaModel,
- ServerModel,
-} from '../../intermediate-representation/model.js';
-import type { CodeSample, Generator, SampleContext } from '../types.js';
-
-const GO = RESERVED_WORDS.go;
-
-/**
- * The package clause the output declares. Rewriting an invalid name would hide the
- * publisher's typo behind a package their imports don't mention, so this rejects it.
- */
-function goPackageName(configured: string | undefined): string {
- if (configured === undefined) return 'client';
- if (!/^[a-z_][a-z0-9_]*$/.test(configured) || GO.has(configured)) {
- throw new NotSupportedError(
- `goPackage "${configured}" is not a valid Go package name: use lowercase letters, digits, and underscores, don't start with a digit, and avoid Go keywords.`
- );
- }
- return configured;
-}
-
-/** An exported Go identifier (PascalCase; keywords can't collide since these start uppercase). */
-function exported(name: string): string {
- const ident = identifierFor(name, { style: 'pascal', reserved: GO });
- // A digit-leading name gets `_`-prefixed by identifierFor, which in Go means
- // UNexported — encoding/json would silently skip the field. `N` (number) keeps it exported.
- return ident.startsWith('_') ? `N${ident.slice(1)}` : ident;
-}
-
-/** The Go type for a schema; `required=false` optionals become pointers at the field site. */
-export function goType(schema: SchemaModel, dateType: DateType = 'string'): string {
- if (isNullable(schema)) {
- const inner = goType(unwrapNullable(schema), dateType);
- return inner.startsWith('*') || inner === 'any' ? inner : `*${inner}`;
- }
- switch (schema.kind) {
- case 'scalar':
- // Under `dateType: Date`, a date-time is a time.Time (encoding/json handles
- // RFC 3339 natively) and a bare date is the runtime's `Date` wrapper.
- if (dateType === 'Date' && schema.scalar === 'string') {
- if (schema.metadata?.format === 'date-time') return 'time.Time';
- if (schema.metadata?.format === 'date') return 'Date';
- }
- return { string: 'string', integer: 'int64', number: 'float64', boolean: 'bool' }[
- schema.scalar
- ];
- case 'array':
- return `[]${goType(schema.items, dateType)}`;
- case 'record':
- return `map[string]${goType(schema.value, dateType)}`;
- case 'ref':
- return exported(schema.name);
- case 'literal':
- return typeof schema.value === 'string'
- ? 'string'
- : typeof schema.value === 'boolean'
- ? 'bool'
- : 'float64';
- case 'enum':
- // Anonymous (inline) enums keep the wire scalar; only NAMED enums get types.
- return { string: 'string', integer: 'int64', number: 'float64', boolean: 'bool' }[
- schema.scalar
- ];
- case 'omit':
- // Go has no Omit; the base struct is the honest annotation (readOnly
- // fields are server-managed and simply omitted from requests).
- return exported(schema.base);
- case 'union':
- case 'null':
- case 'object':
- case 'intersection':
- case 'unknown':
- return 'any';
- }
-}
-
-function writeDocComment(printer: Printer, name: string, description?: string): void {
- const lines = docText(description);
- if (lines.length === 0) return;
- printer.line(`// ${name} — ${lines[0]}`);
- // A blank line inside a description is `//`, never `// ` — gofmt strips the space — and
- // CONSECUTIVE blank lines collapse to one, because gofmt rewrites `//\n//` that way.
- let previousWasBlank = false;
- for (const line of lines.slice(1)) {
- if (line === '') {
- if (!previousWasBlank) printer.line('//');
- previousWasBlank = true;
- continue;
- }
- printer.line(`// ${line}`);
- previousWasBlank = false;
- }
-}
-
-function writeStruct(
- printer: Printer,
- name: string,
- properties: PropertyModel[],
- dateType: DateType,
- description?: string
-): void {
- writeDocComment(printer, exported(name), description);
- printer.block(
- `type ${exported(name)} struct {`,
- () => {
- for (const property of properties) {
- const field = exported(property.name);
- let fieldType = goType(property.schema, dateType);
- let tag = `\`json:"${property.name}"\``;
- if (!property.required) {
- if (
- !fieldType.startsWith('*') &&
- !fieldType.startsWith('[]') &&
- !fieldType.startsWith('map[') &&
- fieldType !== 'any'
- ) {
- fieldType = `*${fieldType}`;
- }
- tag = `\`json:"${property.name},omitempty"\``;
- }
- printer.line(`${field} ${fieldType} ${tag}`);
- }
- },
- '}'
- );
- printer.blank();
-}
-
-/**
- * The whitespace shape gofmt produces: never more than one blank line, and exactly one
- * trailing newline. Both entry points below run through it, so the models view is as
- * gofmt-clean as the full client.
- */
-function gofmtShape(source: string): string {
- return `${source.replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
-}
-
-/** Render every named schema: typed-const enums, structs (allOf flattened), union dispatchers. */
-export function renderGoModels(model: ApiModel, dateType: DateType = 'string'): string {
- const printer = new Printer('\t');
- printer.line('package client');
- printer.blank();
- const needsJSON = model.schemas.some(
- ({ schema }) => discriminatorCases(schema, model) !== undefined
- );
- if (needsJSON) {
- printer.line('import "encoding/json"');
- printer.blank();
- }
- // The models section also compiles standalone (see the unit bars), so it declares
- // its own `time` import when a field is a date.
- const body = renderGoModelBodies(model, dateType);
- if (dateType === 'Date' && body.includes('time.Time')) {
- printer.line('import "time"');
- printer.blank();
- }
- printer.line(body);
- return gofmtShape(alignGoColumns(printer.toString()));
-}
-
-/** The struct/enum/union declarations themselves — the header is renderGoModels' job. */
-function renderGoModelBodies(model: ApiModel, dateType: DateType): string {
- const printer = new Printer('\t');
-
- for (const { name, schema } of model.schemas) {
- const asEnum = enumValues(schema);
- if (asEnum !== undefined) {
- const base = asEnum.scalar === 'string' ? 'string' : 'int64';
- writeDocComment(printer, exported(name), schema.description);
- printer.line(`type ${exported(name)} ${base}`);
- printer.blank();
- printer.block(
- 'const (',
- () => {
- asEnum.values.forEach((value) => {
- const member = exported(name) + casing.pascal(String(value));
- printer.line(`${member} ${exported(name)} = ${JSON.stringify(value)}`);
- });
- },
- ')'
- );
- printer.blank();
- continue;
- }
- if (schema.kind === 'object' || schema.kind === 'intersection') {
- const flat = flattenAllOf(schema, model);
- if (flat !== undefined) {
- writeStruct(
- printer,
- name,
- flat.properties,
- dateType,
- flat.description ?? schema.description
- );
- continue;
- }
- }
- const cases = discriminatorCases(schema, model);
- if (cases !== undefined) {
- const typeName = exported(name);
- const table = cases.cases
- .map((entry) => `${entry.value} -> ${exported(entry.schemaName)}`)
- .join(', ');
- printer.line(`// ${typeName} is a discriminated union ("${cases.property}"): ${table}.`);
- printer.line(`type ${typeName} = any`);
- printer.blank();
- printer.line(
- `// Unmarshal${typeName} decodes into the member selected by "${cases.property}".`
- );
- printer.block(
- `func Unmarshal${typeName}(data []byte) (${typeName}, error) {`,
- () => {
- printer.block(
- 'var probe struct {',
- () => {
- printer.line(`Discriminant string \`json:"${cases.property}"\``);
- },
- '}'
- );
- printer.block(
- 'if err := json.Unmarshal(data, &probe); err != nil {',
- () => {
- printer.line('return nil, err');
- },
- '}'
- );
- // gofmt keeps `case` at the switch's own indent, so the switch body is NOT
- // indented as a block — only each case's statements are.
- printer.line('switch probe.Discriminant {');
- for (const entry of cases.cases) {
- printer.block(`case ${JSON.stringify(entry.value)}:`, () => {
- printer.line(`var value ${exported(entry.schemaName)}`);
- printer.line('err := json.Unmarshal(data, &value)');
- printer.line('return value, err');
- });
- }
- printer.line('}');
- printer.line('var fallback any');
- printer.line('err := json.Unmarshal(data, &fallback)');
- printer.line('return fallback, err');
- },
- '}'
- );
- printer.blank();
- continue;
- }
- // Everything else (plain unions, scalar aliases, records) becomes a type alias.
- writeDocComment(printer, exported(name), schema.description);
- printer.line(`type ${exported(name)} = ${goType(schema, dateType)}`);
- printer.blank();
- }
- return printer.toString();
-}
-
-/** The operation's primary JSON success schema, or undefined for void/no-body ops. */
-function successSchema(op: OperationModel): SchemaModel | undefined {
- return op.successResponses.find((r) => r.contentType.toLowerCase().includes('json'))?.schema;
-}
-
-/** Go composite literal for one operation's security OR-alternatives. */
-function goSecurityLiteral(op: OperationModel, model: ApiModel): string | undefined {
- const alternatives = op.security
- .map((alternative) =>
- alternative.flatMap((key): string[] => {
- const scheme = model.securitySchemes.find((s) => s.key === key);
- if (scheme === undefined) return [];
- if (scheme.kind === 'bearer' || scheme.kind === 'basic') {
- return [`{Scheme: ${JSON.stringify(key)}, Kind: ${JSON.stringify(scheme.kind)}}`];
- }
- const name =
- scheme.kind === 'apiKeyHeader'
- ? scheme.headerName
- : scheme.kind === 'apiKeyQuery'
- ? scheme.paramName
- : scheme.cookieName;
- const location =
- scheme.kind === 'apiKeyHeader'
- ? 'header'
- : scheme.kind === 'apiKeyQuery'
- ? 'query'
- : 'cookie';
- return [
- `{Scheme: ${JSON.stringify(key)}, Kind: "apiKey", Name: ${JSON.stringify(name)}, In: ${JSON.stringify(location)}}`,
- ];
- })
- )
- .filter((alternative) => alternative.length > 0);
- if (alternatives.length === 0) return undefined;
- return `[][]SecuritySpec{${alternatives.map((specs) => `{${specs.join(', ')}}`).join(', ')}}`;
-}
-
-/** Every operation with its collision-free exported Go method name. */
-function goOperationIdents(model: ApiModel): Array<{ op: OperationModel; ident: string }> {
- const used = new Set();
- const out: Array<{ op: OperationModel; ident: string }> = [];
- for (const service of model.services) {
- for (const op of service.operations) {
- let ident = exported(op.name);
- let suffix = 2;
- while (used.has(ident)) ident = `${exported(op.name)}${suffix++}`;
- used.add(ident);
- out.push({ op, ident });
- }
- }
- return out;
-}
-
-/** A query-value expression formatted to string for url.Values. */
-function goQueryFormat(expr: string, type: string): string {
- if (type === 'string') return expr;
- // Dates serialize in their wire layout, not Go's default String(). A dereferenced
- // pointer needs parentheses: `*p.Format(…)` would deref Format's result.
- const receiver = expr.startsWith('*') ? `(${expr})` : expr;
- if (type === 'time.Time') return `${receiver}.Format(time.RFC3339)`;
- if (type === 'Date') return `${receiver}.Format("2006-01-02")`;
- if (type === 'int64') return `strconv.FormatInt(${expr}, 10)`;
- if (type === 'float64') return `strconv.FormatFloat(${expr}, 'f', -1, 64)`;
- if (type === 'bool') return `strconv.FormatBool(${expr})`;
- return `fmt.Sprint(${expr})`;
-}
-
-/**
- * Align columns the way gofmt does, so the emitted file is already idiomatic and a
- * `gofmt` run is a no-op. gofmt pads with spaces inside a contiguous run of similar
- * lines: struct fields align their type and tag columns, `const`/`var` entries align
- * their type and `=`. A line that doesn't fit the shape (a comment, a blank line, a
- * type containing spaces) ends the run, exactly like gofmt's tabwriter.
- */
-function alignGoColumns(source: string): string {
- const lines = source.split('\n');
- const out = [...lines];
- // `\tName Type` optionally followed by a `json:"…"` tag, `\tName Type = value`, or a
- // quoted map key. A statement starting with a Go keyword (`case "x":`, `return y`) is
- // NOT a declaration and must never be padded.
- const FIELD = /^(\t+)([A-Za-z_]\w*) (\S+)( `[^`]*`)?$/;
- const CONST = /^(\t+)([A-Za-z_]\w*) (\S+) = (.+)$/;
- const ENTRY = /^(\t+)("(?:[^"\\]|\\.)*":) (.+)$/;
-
- const flush = (run: Array<{ index: number; parts: string[]; indent: string }>): void => {
- if (run.length < 2) return;
- const widths: number[] = [];
- for (const { parts } of run) {
- parts.forEach((part, column) => {
- // The last column never needs padding.
- if (column < parts.length - 1) widths[column] = Math.max(widths[column] ?? 0, part.length);
- });
- }
- for (const { index, parts, indent } of run) {
- const padded = parts.map((part, column) =>
- column < parts.length - 1 ? part.padEnd(widths[column] ?? 0) : part
- );
- out[index] = indent + padded.join(' ').trimEnd();
- }
- };
-
- let run: Array<{ index: number; parts: string[]; indent: string }> = [];
- let runKind: 'field' | 'const' | 'entry' | undefined;
- lines.forEach((line, index) => {
- const entryMatch = ENTRY.exec(line);
- const constMatch = entryMatch === null ? CONST.exec(line) : null;
- const fieldCandidate = entryMatch === null && constMatch === null ? FIELD.exec(line) : null;
- // `case`, `return`, `var`, … start statements, not declarations.
- const fieldMatch =
- fieldCandidate !== null && !GO.has(fieldCandidate[2]) ? fieldCandidate : null;
- const kind =
- entryMatch !== null
- ? 'entry'
- : constMatch !== null
- ? 'const'
- : fieldMatch !== null
- ? 'field'
- : undefined;
- if (kind === undefined || kind !== runKind) {
- flush(run);
- run = [];
- runKind = kind;
- }
- if (entryMatch !== null) {
- run.push({ index, indent: entryMatch[1], parts: [entryMatch[2], entryMatch[3]] });
- return;
- }
- if (constMatch !== null) {
- run.push({
- index,
- indent: constMatch[1],
- parts: [constMatch[2], constMatch[3], '=', constMatch[4]],
- });
- return;
- }
- if (fieldMatch !== null) {
- const parts = [fieldMatch[2], fieldMatch[3]];
- if (fieldMatch[4] !== undefined) parts.push(fieldMatch[4].trimStart());
- run.push({ index, indent: fieldMatch[1], parts });
- }
- });
- flush(run);
- return out.join('\n');
-}
+ type OperationModel,
+ paginationItemSchema,
+ renderReferencePage,
+ type SampleContext,
+ sseResponse,
+} from '@redocly/client-generator';
+import { exported, GoPrinter } from '@redocly/client-generator/printers/go';
+import { GO_RUNTIME_SOURCE } from '@redocly/client-generator/runtime-sources';
+
+import { writeGoServers } from './client.ts';
+import { goPaginationLiteral, goSecurityLiteral } from './descriptor.ts';
+import { renderGoModels } from './models.ts';
+import { GO, goOperationIdents, goPackageName, naming } from './naming.ts';
+import { writeGoMethod } from './operations.ts';
+import { writeGoPaginationWrappers } from './pagination.ts';
+import { goType } from './types.ts';
+
+export { renderGoModels } from './models.ts';
+export { goType } from './types.ts';
/** Strip the package clause and import lines/blocks so a section stitches into one file. */
function stripHeader(source: string): string {
@@ -450,564 +55,45 @@ function stripHeader(source: string): string {
return out.join('\n').trim();
}
-/** The op's SSE success response, when it streams text/event-stream. */
-function sseResponse(op: OperationModel) {
- return op.successResponses.find((response) =>
- response.contentType.toLowerCase().includes('text/event-stream')
- );
-}
-
-function isMultipart(op: OperationModel): boolean {
- return op.requestBody?.contentType.toLowerCase().includes('multipart') ?? false;
-}
-
-/** The neutral rule as a `&PaginationSpec{…}` composite literal for the operations table. */
-function goPaginationLiteral(rule: NeutralPaginationRule): string {
- const fields = [
- `Style: ${JSON.stringify(rule.style)}`,
- ...(rule.param !== undefined ? [`Param: ${JSON.stringify(rule.param)}`] : []),
- ...(rule.nextCursor !== undefined ? [`NextCursor: ${JSON.stringify(rule.nextCursor)}`] : []),
- ...(rule.hasMore !== undefined ? [`HasMore: ${JSON.stringify(rule.hasMore)}`] : []),
- ...(rule.limitParam !== undefined ? [`LimitParam: ${JSON.stringify(rule.limitParam)}`] : []),
- ...(rule.items !== undefined ? [`Items: ${JSON.stringify(rule.items)}`] : []),
- ];
- return `&PaginationSpec{${fields.join(', ')}}`;
-}
-
-/**
- * The argument names a method declares beside its path parameters: the receiver, the
- * context, the request body, and the query struct.
- */
-const METHOD_ARG_SLOTS = ['c', 'ctx', 'body', 'params', 'out', 'op'];
+/** Every stdlib package the merged inline file needs (the runtime dominates the list). */
+const GO_STDLIB_IMPORTS = [
+ 'bytes',
+ 'context',
+ 'encoding/base64',
+ 'encoding/json',
+ 'errors',
+ 'fmt',
+ 'io',
+ 'math/rand',
+ 'mime/multipart',
+ 'net/http',
+ 'net/url',
+ 'strconv',
+ 'strings',
+ 'time',
+];
/**
- * Path parameters as Go arguments, uniquely named. A parameter named after one of the
- * method's own arguments (or a name a description reuses across locations) moves aside as
- * `id2` — Go rejects a duplicate parameter, and the wire name is untouched either way.
+ * Everything below the import block: models, servers, the (optionally embedded)
+ * runtime, the operations table, and the Client — one emission path for both
+ * runtime modes, so module mode cannot drift from the inline layout.
*/
-function pathArguments(
- op: OperationModel,
- dateType: DateType
-): Array<{ param: ParamModel; go: string; type: string }> {
- const names = uniqueIdentifiers(
- op.pathParams.map((param) => param.name),
- { style: 'camel', reserved: GO, taken: METHOD_ARG_SLOTS }
- );
- return op.pathParams.map((param, index) => ({
- param,
- go: names[index],
- type: goType(param.schema, dateType),
- }));
-}
-
-/** Declared response headers planned for the `Headers` struct: field, wire name, coerce helper. */
-function envelopeHeaderPlan(
- op: OperationModel,
- model: ApiModel
-): Array<{ field: string; name: string; goType: string; helper: string }> {
- const used = new Set();
- return (op.successResponseHeaders ?? []).map((header) => {
- const base = exported(header.name);
- let field = base;
- let suffix = 2;
- while (used.has(field)) field = `${base}${suffix++}`;
- used.add(field);
- const coerce = headerCoerceType(header.schema, model);
- const mapping = {
- integer: { goType: '*int64', helper: 'headerInt64' },
- number: { goType: '*float64', helper: 'headerFloat64' },
- boolean: { goType: '*bool', helper: 'headerBool' },
- string: { goType: '*string', helper: 'headerString' },
- }[coerce];
- return { field, name: header.name, ...mapping };
- });
-}
-
-function writeGoMethod(
- printer: Printer,
- op: OperationModel,
- ident: string,
+function writeGoBody(
+ printer: GoPrinter,
+ model: ApiModel,
+ emit: EmitOptions,
dateType: DateType,
- model?: ApiModel,
- envelope = false
+ paginationRules: Map,
+ embedRuntime: boolean
): void {
- const pathArgs = pathArguments(op, dateType);
- const hasParams = op.queryParams.length > 0;
- const success = successSchema(op);
- const returnType = success === undefined ? undefined : goType(success, dateType);
- const headerPlan = envelope ? envelopeHeaderPlan(op, model!) : [];
- if (envelope) {
- printer.line(
- `// ${ident}Headers carries the declared response headers of ${ident}WithHeaders (nil when absent or unparsable).`
- );
- printer.block(
- `type ${ident}Headers struct {`,
- () => {
- for (const planned of headerPlan) printer.line(`${planned.field} ${planned.goType}`);
- },
- '}'
- );
- printer.blank();
- }
- const args = [
- 'ctx context.Context',
- ...pathArgs.map(({ go, type }) => `${go} ${type}`),
- ...(op.requestBody ? [`body ${goType(op.requestBody.schema, dateType)}`] : []),
- ...(hasParams ? [`params *${ident}Params`] : []),
- ];
- const sse = sseResponse(op);
- const returns = envelope
- ? returnType === undefined
- ? `(${ident}Headers, error)`
- : `(${returnType}, ${ident}Headers, error)`
- : sse !== undefined
- ? 'func(yield func(ServerSentEvent, error) bool)'
- : returnType === undefined
- ? 'error'
- : `(${returnType}, error)`;
- const fail = (errExpr: string) =>
- envelope
- ? returnType === undefined
- ? `return headers, ${errExpr}`
- : `return out, headers, ${errExpr}`
- : returnType === undefined
- ? `return ${errExpr}`
- : `return out, ${errExpr}`;
- const funcName = envelope ? `${ident}WithHeaders` : ident;
- writeDocComment(
- printer,
- funcName,
- envelope ? `Like ${ident}, also returning the declared response headers.` : op.summary
- );
- printer.block(
- `func (c *Client) ${funcName}(${args.join(', ')}) ${returns} {`,
- () => {
- if (sse === undefined && returnType !== undefined) printer.line(`var out ${returnType}`);
- if (envelope) printer.line(`var headers ${ident}Headers`);
- printer.line(`op := operations[${JSON.stringify(op.specName ?? op.name)}]`);
- printer.line('authHeaders, query := resolveAuth(op.Security, c.config.Auth)');
- if (hasParams) {
- printer.block(
- 'if params != nil {',
- () => {
- for (const param of op.queryParams) {
- const field = exported(param.name);
- printer.block(
- `if params.${field} != nil {`,
- () => {
- printer.line(
- `query.Set(${JSON.stringify(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema, dateType))})`
- );
- },
- '}'
- );
- }
- },
- '}'
- );
- }
- const pathDict = pathArgs
- .map(({ param, go, type }) => `${JSON.stringify(param.name)}: ${goQueryFormat(go, type)}`)
- .join(', ');
- printer.line(
- `requestURL := buildURL(c.config.ServerURL, op.Path, map[string]string{${pathDict}})`
- );
- if (sse !== undefined) {
- printer.block(
- 'open := func(extraHeaders map[string]string) (*http.Response, error) {',
- () => {
- printer.line('merged := map[string]string{}');
- printer.block(
- 'for key, value := range authHeaders {',
- () => {
- printer.line('merged[key] = value');
- },
- '}'
- );
- printer.block(
- 'for key, value := range extraHeaders {',
- () => {
- printer.line('merged[key] = value');
- },
- '}'
- );
- printer.line(
- 'return send(ctx, &c.config, requestSpec{OperationID: op.ID, Method: op.Method, URL: requestURL, Headers: merged, Query: query})'
- );
- },
- '}'
- );
- printer.line(
- `return iterSSE(open, ${sse.schema !== undefined && sse.schema.kind !== 'unknown'})`
- );
- return;
- }
- const specFields = [
- 'OperationID: op.ID',
- 'Method: op.Method',
- 'URL: requestURL',
- 'Headers: authHeaders',
- 'Query: query',
- ];
- if (op.requestBody && isMultipart(op)) {
- printer.line('contentType, reader, err := toMultipart(body)');
- printer.block(
- 'if err != nil {',
- () => {
- printer.line(fail('err'));
- },
- '}'
- );
- specFields.push('Body: reader');
- specFields.push('ContentType: contentType');
- } else if (op.requestBody) {
- printer.line('payload, err := json.Marshal(body)');
- printer.block(
- 'if err != nil {',
- () => {
- printer.line(fail('err'));
- },
- '}'
- );
- specFields.push('Body: bytes.NewReader(payload)');
- specFields.push(`ContentType: ${JSON.stringify(op.requestBody.contentType)}`);
- }
- printer.line(`resp, err := send(ctx, &c.config, requestSpec{${specFields.join(', ')}})`);
- printer.block(
- 'if err != nil {',
- () => {
- printer.line(fail('err'));
- },
- '}'
- );
- printer.block(
- 'if resp.StatusCode >= 400 {',
- () => {
- printer.line(fail('apiErrorFrom(resp, requestURL)'));
- },
- '}'
- );
- if (envelope) {
- printer.block(
- `if err := decodeJSON(resp, ${returnType === undefined ? 'nil' : '&out'}); err != nil {`,
- () => {
- printer.line(fail('err'));
- },
- '}'
- );
- for (const planned of headerPlan) {
- printer.line(
- `headers.${planned.field} = ${planned.helper}(resp.Header, ${JSON.stringify(planned.name)})`
- );
- }
- printer.line(returnType === undefined ? 'return headers, nil' : 'return out, headers, nil');
- } else if (returnType === undefined) {
- printer.line('return decodeJSON(resp, nil)');
- } else {
- printer.block(
- 'if err := decodeJSON(resp, &out); err != nil {',
- () => {
- printer.line('return out, err');
- },
- '}'
- );
- printer.line('return out, nil');
- }
- },
- '}'
- );
- printer.blank();
-}
-
-/** `Pages` / `Items` iterators over the runtime's `iterPages`, hydrated via `reencode`. */
-function writeGoPaginationWrappers(
- printer: Printer,
- op: OperationModel,
- ident: string,
- dateType: DateType,
- pageType: string,
- itemType: string
-): void {
- const pathArgs = pathArguments(op, dateType);
- const hasParams = op.queryParams.length > 0;
- const args = [
- 'ctx context.Context',
- ...pathArgs.map(({ go, type }) => `${go} ${type}`),
- ...(hasParams ? [`params *${ident}Params`] : []),
- ].join(', ');
-
- const writeCallClosure = () => {
- printer.line(`op := operations[${JSON.stringify(op.specName ?? op.name)}]`);
- printer.line('base := url.Values{}');
- if (hasParams) {
- printer.block(
- 'if params != nil {',
- () => {
- for (const param of op.queryParams) {
- const field = exported(param.name);
- printer.block(
- `if params.${field} != nil {`,
- () => {
- printer.line(
- `base.Set(${JSON.stringify(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema, dateType))})`
- );
- },
- '}'
- );
- }
- },
- '}'
- );
- }
- printer.block(
- 'call := func(pageParams url.Values) (any, *http.Response, error) {',
- () => {
- printer.line('authHeaders, query := resolveAuth(op.Security, c.config.Auth)');
- printer.block(
- 'for key, values := range pageParams {',
- () => {
- printer.block(
- 'for _, value := range values {',
- () => {
- printer.line('query.Set(key, value)');
- },
- '}'
- );
- },
- '}'
- );
- const pathDict = pathArgs
- .map(({ param, go, type }) => `${JSON.stringify(param.name)}: ${goQueryFormat(go, type)}`)
- .join(', ');
- printer.line(
- `requestURL := buildURL(c.config.ServerURL, op.Path, map[string]string{${pathDict}})`
- );
- printer.line(
- 'resp, err := send(ctx, &c.config, requestSpec{OperationID: op.ID, Method: op.Method, URL: requestURL, Headers: authHeaders, Query: query})'
- );
- printer.block(
- 'if err != nil {',
- () => {
- printer.line('return nil, nil, err');
- },
- '}'
- );
- printer.block(
- 'if resp.StatusCode >= 400 {',
- () => {
- printer.line('return nil, resp, apiErrorFrom(resp, requestURL)');
- },
- '}'
- );
- printer.line('var raw any');
- printer.block(
- 'if err := decodeJSON(resp, &raw); err != nil {',
- () => {
- printer.line('return nil, resp, err');
- },
- '}'
- );
- printer.line('return raw, resp, nil');
- },
- '}'
- );
- printer.line('pages := iterPages(call, *op.Pagination, base)');
- };
-
- printer.line(
- `// ${ident}Pages iterates ${ident} response pages; use with \`for page, err := range\`.`
- );
- printer.block(
- `func (c *Client) ${ident}Pages(${args}) func(yield func(${pageType}, error) bool) {`,
- () => {
- writeCallClosure();
- printer.block(
- `return func(yield func(${pageType}, error) bool) {`,
- () => {
- printer.block(
- 'pages(func(raw any, err error) bool {',
- () => {
- printer.line(`var page ${pageType}`);
- printer.block(
- 'if err == nil {',
- () => {
- printer.line('err = reencode(raw, &page)');
- },
- '}'
- );
- printer.line('return yield(page, err)');
- },
- '})'
- );
- },
- '}'
- );
- },
- '}'
- );
- printer.blank();
-
- printer.line(`// ${ident}Items iterates the items of every ${ident} page.`);
- printer.block(
- `func (c *Client) ${ident}Items(${args}) func(yield func(${itemType}, error) bool) {`,
- () => {
- writeCallClosure();
- printer.block(
- `return func(yield func(${itemType}, error) bool) {`,
- () => {
- printer.block(
- 'pages(func(raw any, err error) bool {',
- () => {
- printer.block(
- 'if err != nil {',
- () => {
- printer.line(`var zero ${itemType}`);
- printer.line('return yield(zero, err)');
- },
- '}'
- );
- printer.line('pageItems, _ := resolvePointer(raw, op.Pagination.Items).([]any)');
- printer.block(
- 'for _, item := range pageItems {',
- () => {
- printer.line(`var typed ${itemType}`);
- printer.block(
- 'if err := reencode(item, &typed); err != nil {',
- () => {
- printer.line('return yield(typed, err)');
- },
- '}'
- );
- printer.block(
- 'if !yield(typed, nil) {',
- () => {
- printer.line('return false');
- },
- '}'
- );
- },
- '}'
- );
- printer.line('return true');
- },
- '})'
- );
- },
- '}'
- );
- },
- '}'
- );
- printer.blank();
-}
-
-/** The server URL as a Go expression: literals concatenated with declared-variable params. */
-function serverUrlExpression(server: ServerModel): string {
- const declared = new Set(server.variables.map((variable) => variable.name));
- const parts: string[] = [];
- let literal = '';
- let rest = server.url;
- const template = /\{([^{}]+)\}/;
- for (let match = template.exec(rest); match !== null; match = template.exec(rest)) {
- literal += rest.slice(0, match.index);
- if (declared.has(match[1])) {
- if (literal !== '') parts.push(JSON.stringify(literal));
- literal = '';
- parts.push(identifierFor(match[1], { style: 'camel', reserved: GO }));
- } else {
- // An undeclared variable has nothing to substitute; keep its placeholder visible.
- literal += match[0];
- }
- rest = rest.slice(match.index + match[0].length);
- }
- literal += rest;
- if (literal !== '' || parts.length === 0) parts.push(JSON.stringify(literal));
- return parts.join(' + ');
-}
-
-/** One `URL` function per declared server; server variables become parameters. */
-function writeGoServers(printer: Printer, model: ApiModel): void {
- const servers = model.servers ?? [];
- if (servers.length === 0) return;
- const usedNames = new Set();
- servers.forEach((server, index) => {
- let name = `${exported(server.description ?? `server${index + 1}`)}URL`;
- if (usedNames.has(name)) name = `${name}${index + 1}`;
- usedNames.add(name);
- const params = server.variables.map(
- (variable) => `${identifierFor(variable.name, { style: 'camel', reserved: GO })} string`
- );
- const defaults = server.variables
- .map(
- (variable) =>
- `${identifierFor(variable.name, { style: 'camel', reserved: GO })} default: ${JSON.stringify(variable.default)}`
- )
- .join(', ');
- printer.line(
- `// ${name} returns the ${JSON.stringify(server.description ?? server.url)} base URL${defaults === '' ? '.' : ` (${defaults}).`}`
- );
- printer.block(
- `func ${name}(${params.join(', ')}) string {`,
- () => {
- printer.line(`return ${serverUrlExpression(server)}`);
- },
- '}'
- );
- printer.blank();
- });
-}
-
-/** The whole generated file: models + embedded runtime + operations table + Client. */
-export const goGenerator: Generator = ({ model, outputPath, emit }) => {
- const printer = new Printer('\t');
- const dateType = emit.dateType ?? 'string';
- const packageName = goPackageName(emit.goPackage);
- const paginationRules = new Map();
- for (const { op, ident } of goOperationIdents(model)) {
- const rule = paginationRuleFor(op, emit.pagination as Record | undefined);
- if (rule !== undefined) paginationRules.set(ident, rule);
- }
- printer.line(
- `// Code generated by @redocly/client-generator (go) from "${model.title}" ${model.version}. DO NOT EDIT.`
- );
- printer.line(
- '// Regenerate with `redocly generate-client`. Standard library only — zero dependencies.'
- );
- printer.line(`package ${packageName}`);
- printer.blank();
- // One merged import block: the runtime uses every entry; generated code uses a subset.
- printer.block(
- 'import (',
- () => {
- for (const spec of [
- 'bytes',
- 'context',
- 'encoding/base64',
- 'encoding/json',
- 'errors',
- 'fmt',
- 'io',
- 'math/rand',
- 'mime/multipart',
- 'net/http',
- 'net/url',
- 'strconv',
- 'strings',
- 'time',
- ]) {
- printer.line(JSON.stringify(spec));
- }
- },
- ')'
- );
- printer.blank();
-
printer.line(stripHeader(renderGoModels(model, dateType)));
printer.blank();
writeGoServers(printer, model);
- printer.line('// ─── Embedded runtime (@redocly/client-generator go runtime) ───');
- printer.line(stripHeader(GO_RUNTIME_SOURCE));
- printer.blank();
+ if (embedRuntime) {
+ printer.line('// ─── Embedded runtime (@redocly/client-generator go runtime) ───');
+ printer.line(stripHeader(GO_RUNTIME_SOURCE));
+ printer.blank();
+ }
printer.block(
'type operationMeta struct {',
@@ -1029,13 +115,13 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => {
const security = goSecurityLiteral(op, model);
const rule = paginationRules.get(ident);
const fields = [
- `ID: ${JSON.stringify(id)}`,
- `Method: ${JSON.stringify(op.method.toUpperCase())}`,
- `Path: ${JSON.stringify(op.path)}`,
+ `ID: ${naming.string(id)}`,
+ `Method: ${naming.string(op.method.toUpperCase())}`,
+ `Path: ${naming.string(op.path)}`,
...(security !== undefined ? [`Security: ${security}`] : []),
...(rule !== undefined ? [`Pagination: ${goPaginationLiteral(rule)}`] : []),
];
- printer.line(`${JSON.stringify(id)}: {${fields.join(', ')}},`);
+ printer.line(`${naming.string(id)}: {${fields.join(', ')}},`);
}
},
'}'
@@ -1060,7 +146,7 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => {
printer.blank();
}
- writeDocComment(printer, 'Client', `Client for ${model.title} (${model.version}).`);
+ printer.doc('Client', `Client for ${model.title} (${model.version}).`);
printer.block(
'type Client struct {',
() => {
@@ -1076,7 +162,7 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => {
'if config.ServerURL == "" {',
() => {
printer.line(
- `config.ServerURL = ${JSON.stringify(emit.serverUrl ?? model.serverUrl ?? '')}`
+ `config.ServerURL = ${naming.string(emit.serverUrl ?? model.serverUrl ?? '')}`
);
},
'}'
@@ -1094,15 +180,9 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => {
}
const rule = paginationRules.get(ident);
if (rule === undefined) continue;
- const success = successSchema(op);
+ const success = jsonSuccessSchema(op);
const pageType = success === undefined ? 'any' : goType(success, dateType);
- // Resolve the items ARRAY, then take its raw element, so a `ref` element
- // keeps its name (a deref'd result would type as `any`).
- const itemsArray =
- success !== undefined && rule.items !== undefined
- ? schemaAtPointer(success, rule.items, model)
- : undefined;
- const element = itemsArray?.kind === 'array' ? itemsArray.items : undefined;
+ const element = paginationItemSchema(success, rule.items, model);
writeGoPaginationWrappers(
printer,
op,
@@ -1112,13 +192,69 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => {
element === undefined ? 'any' : goType(element, dateType)
);
}
+}
+
+/** The whole generated file: models + embedded runtime + operations table + Client. */
+export const goGenerator: Generator = ({ model, output, banner, emit, pagination }) => {
+ const printer = new GoPrinter();
+ const dateType = emit.dateType ?? 'string';
+ const packageName = goPackageName(emit.goPackage);
+ // Pagination arrives RESOLVED from the pipeline — one fit-verified answer per run.
+ const paginationRules = new Map();
+ for (const { op, ident } of goOperationIdents(model)) {
+ const spec = pagination?.get(op.name)?.spec;
+ if (spec !== undefined) paginationRules.set(ident, spec);
+ }
+ printer.line(
+ `// Code generated by @redocly/client-generator (go) from "${model.title}" ${model.version}. DO NOT EDIT.`
+ );
+ printer.line(
+ '// Regenerate with `redocly generate-client`. Standard library only — zero dependencies.'
+ );
+ printer.line(`package ${packageName}`);
+ printer.blank();
+ const embedRuntime = emit.runtime !== 'module';
+ // One merged import block. Inline: the runtime uses every entry. Module: the runtime
+ // imports for itself, so the client lists only the packages its own body references —
+ // an unused import is a Go compile error, so the subset is derived from the body text.
+ const imports = embedRuntime
+ ? GO_STDLIB_IMPORTS
+ : (() => {
+ const scratch = new GoPrinter();
+ writeGoBody(scratch, model, emit, dateType, paginationRules, false);
+ const body = scratch.toString();
+ return GO_STDLIB_IMPORTS.filter((spec) =>
+ new RegExp(`\\b${spec.split('/').pop()}\\.`).test(body)
+ );
+ })();
+ printer.block(
+ 'import (',
+ () => {
+ for (const spec of imports) {
+ printer.line(naming.string(spec));
+ }
+ },
+ ')'
+ );
+ printer.blank();
+ writeGoBody(printer, model, emit, dateType, paginationRules, embedRuntime);
+
+ const entry = {
+ path: output.path.replace(/\.[^.\\/]+$/, '.go'),
+ // Sections are stitched with their own trailing blanks; gofmt allows at most one
+ // between declarations and none at the end of the file.
+ content: printer.toString(),
+ };
+ if (embedRuntime) return [entry];
+ // The runtime, verbatim except the package clause — same directory, same Go package.
+ const header = banner.map((line) => `// ${line}`).join('\n');
+ const runtimeSource = GO_RUNTIME_SOURCE.replace(/^package .*$/m, `package ${packageName}`);
return [
+ entry,
{
- path: outputPath.replace(/\.[^.\\/]+$/, '.go'),
- // Sections are stitched with their own trailing blanks; gofmt allows at most one
- // between declarations and none at the end of the file.
- content: gofmtShape(alignGoColumns(printer.toString())),
+ path: entry.path.replace(/[^\\/]+$/, 'runtime.go'),
+ content: `${header}\n${runtimeSource.trimEnd()}\n`,
},
];
};
@@ -1128,7 +264,11 @@ export function goSample(op: OperationModel, ctx: SampleContext): CodeSample {
const dateType = ctx.emit.dateType ?? 'string';
// `goPackage` renames the package clause, and the snippet qualifies with it.
const pkg = ctx.emit.goPackage ?? 'client';
- const ident = exported(op.name);
+ // The DEDUPED name: on a collision the method is `GetUser2`, and a snippet naming the
+ // raw `GetUser` would show a call that goes to a different operation.
+ const ident =
+ goOperationIdents(ctx.model).find((entry) => entry.op.name === op.name)?.ident ??
+ exported(op.name);
const args = [
'ctx',
...op.pathParams.map(
@@ -1137,10 +277,19 @@ export function goSample(op: OperationModel, ctx: SampleContext): CodeSample {
...(op.requestBody ? [`${goType(op.requestBody.schema, dateType)}{ /* … */ }`] : []),
...(op.queryParams.length > 0 ? ['nil'] : []),
];
+ // The assignment matches the return shape: an SSE method returns one iterator, a void
+ // method returns `error` alone — `result, err :=` would not compile against either.
+ const call = `client.${ident}(${args.join(', ')})`;
+ const statement =
+ sseResponse(op) !== undefined
+ ? `stream := ${call}`
+ : jsonSuccessSchema(op) === undefined
+ ? `err := ${call}`
+ : `result, err := ${call}`;
return {
lang: 'go',
label: 'Go SDK',
- source: `client := ${pkg}.New(${pkg}.Config{})\nresult, err := client.${ident}(${args.join(', ')})\n`,
+ source: `client := ${pkg}.New(${pkg}.Config{})\n${statement}\n`,
};
}
@@ -1149,9 +298,9 @@ export function goSample(op: OperationModel, ctx: SampleContext): CodeSample {
* from `goSample` — this generator's own hook — so the page can only ever show the syntax
* of the SDK beside it, and ejecting this generator takes the page with it.
*/
-export const goDocs: Generator = ({ model, outputPath, emit }) => [
+export const goDocs: Generator = ({ model, output, emit, pagination }) => [
{
- path: outputPath.replace(/\.[^.\\/]+$/, '.go.md'),
+ path: output.path.replace(/\.[^.\\/]+$/, '.go.md'),
content: renderReferencePage(model, {
title: `${model.title} Go SDK reference`,
frontmatter: emit.docsFrontmatter === true,
@@ -1161,8 +310,8 @@ export const goDocs: Generator = ({ model, outputPath, emit }) => [
fence: 'go',
requires: 'The SDK needs the standard library only.',
},
- sample: (op) => goSample(op, { model, emit, outputPath }),
- pagination: emit.pagination,
+ sample: (op) => goSample(op, { model, emit, outputPath: output.path }),
+ paginated: new Set(pagination?.keys() ?? []),
}),
},
];
diff --git a/packages/client-generator/src/generators/go/models.ts b/packages/client-generator/src/generators/go/models.ts
new file mode 100644
index 0000000000..2f15e8b754
--- /dev/null
+++ b/packages/client-generator/src/generators/go/models.ts
@@ -0,0 +1,175 @@
+// The `models` stage: named schemas as typed-const enums, structs with json tags
+// (allOf flattened), and discriminated unions with unmarshal dispatchers.
+
+import {
+ type ApiModel,
+ casing,
+ type DateType,
+ discriminatorCases,
+ enumValues,
+ flattenAllOf,
+ type PropertyModel,
+} from '@redocly/client-generator';
+import { exported, GoPrinter } from '@redocly/client-generator/printers/go';
+
+import { naming } from './naming.ts';
+import { goType } from './types.ts';
+
+function writeStruct(
+ printer: GoPrinter,
+ name: string,
+ properties: PropertyModel[],
+ dateType: DateType,
+ description?: string
+): void {
+ printer.doc(exported(name), description);
+ printer.block(
+ `type ${exported(name)} struct {`,
+ () => {
+ for (const property of properties) {
+ const field = exported(property.name);
+ let fieldType = goType(property.schema, dateType);
+ let tag = `\`json:"${property.name}"\``;
+ if (!property.required) {
+ if (
+ !fieldType.startsWith('*') &&
+ !fieldType.startsWith('[]') &&
+ !fieldType.startsWith('map[') &&
+ fieldType !== 'any'
+ ) {
+ fieldType = `*${fieldType}`;
+ }
+ tag = `\`json:"${property.name},omitempty"\``;
+ }
+ printer.line(`${field} ${fieldType} ${tag}`);
+ }
+ },
+ '}'
+ );
+ printer.blank();
+}
+
+/** Render every named schema: typed-const enums, structs (allOf flattened), union dispatchers. */
+export function renderGoModels(model: ApiModel, dateType: DateType = 'string'): string {
+ const printer = new GoPrinter();
+ printer.line('package client');
+ printer.blank();
+ const needsJSON = model.schemas.some(
+ ({ schema }) => discriminatorCases(schema, model) !== undefined
+ );
+ if (needsJSON) {
+ printer.line('import "encoding/json"');
+ printer.blank();
+ }
+ // The models section also compiles standalone (see the unit bars), so it declares
+ // its own `time` import when a field is a date.
+ const body = renderGoModelBodies(model, dateType);
+ if (dateType === 'Date' && body.includes('time.Time')) {
+ printer.line('import "time"');
+ printer.blank();
+ }
+ printer.line(body);
+ return printer.toString();
+}
+
+/** The struct/enum/union declarations themselves — the header is renderGoModels' job. */
+function renderGoModelBodies(model: ApiModel, dateType: DateType): string {
+ const printer = new GoPrinter();
+
+ for (const { name, schema } of model.schemas) {
+ const asEnum = enumValues(schema);
+ if (asEnum !== undefined) {
+ const base = asEnum.scalar === 'string' ? 'string' : 'int64';
+ printer.doc(exported(name), schema.description);
+ printer.line(`type ${exported(name)} ${base}`);
+ printer.blank();
+ printer.block(
+ 'const (',
+ () => {
+ // Two values may fold to one pascal name (`1.5` and `15`) — a duplicate const
+ // would not compile, so the names are made unique per enum. A digit-leading
+ // value needs no `_` prefix here: the member starts with the type name.
+ const used = new Set();
+ asEnum.values.forEach((value) => {
+ const base = casing.pascal(String(value)) || 'Value';
+ let suffix = '';
+ for (let n = 2; used.has(base + suffix); n++) suffix = String(n);
+ used.add(base + suffix);
+ const member = exported(name) + base + suffix;
+ printer.line(`${member} ${exported(name)} = ${naming.literal(value)}`);
+ });
+ },
+ ')'
+ );
+ printer.blank();
+ continue;
+ }
+ if (schema.kind === 'object' || schema.kind === 'intersection') {
+ const flat = flattenAllOf(schema, model);
+ if (flat !== undefined) {
+ writeStruct(
+ printer,
+ name,
+ flat.properties,
+ dateType,
+ flat.description ?? schema.description
+ );
+ continue;
+ }
+ }
+ const cases = discriminatorCases(schema, model);
+ if (cases !== undefined) {
+ const typeName = exported(name);
+ const table = cases.cases
+ .map((entry) => `${entry.value} -> ${exported(entry.schemaName)}`)
+ .join(', ');
+ printer.line(`// ${typeName} is a discriminated union ("${cases.property}"): ${table}.`);
+ printer.line(`type ${typeName} = any`);
+ printer.blank();
+ printer.line(
+ `// Unmarshal${typeName} decodes into the member selected by "${cases.property}".`
+ );
+ printer.block(
+ `func Unmarshal${typeName}(data []byte) (${typeName}, error) {`,
+ () => {
+ printer.block(
+ 'var probe struct {',
+ () => {
+ printer.line(`Discriminant string \`json:"${cases.property}"\``);
+ },
+ '}'
+ );
+ printer.block(
+ 'if err := json.Unmarshal(data, &probe); err != nil {',
+ () => {
+ printer.line('return nil, err');
+ },
+ '}'
+ );
+ // gofmt keeps `case` at the switch's own indent, so the switch body is NOT
+ // indented as a block — only each case's statements are.
+ printer.line('switch probe.Discriminant {');
+ for (const entry of cases.cases) {
+ printer.block(`case ${naming.string(entry.value)}:`, () => {
+ printer.line(`var value ${exported(entry.schemaName)}`);
+ printer.line('err := json.Unmarshal(data, &value)');
+ printer.line('return value, err');
+ });
+ }
+ printer.line('}');
+ printer.line('var fallback any');
+ printer.line('err := json.Unmarshal(data, &fallback)');
+ printer.line('return fallback, err');
+ },
+ '}'
+ );
+ printer.blank();
+ continue;
+ }
+ // Everything else (plain unions, scalar aliases, records) becomes a type alias.
+ printer.doc(exported(name), schema.description);
+ printer.line(`type ${exported(name)} = ${goType(schema, dateType)}`);
+ printer.blank();
+ }
+ return printer.toString();
+}
diff --git a/packages/client-generator/src/generators/go/naming.ts b/packages/client-generator/src/generators/go/naming.ts
new file mode 100644
index 0000000000..521ef14a9d
--- /dev/null
+++ b/packages/client-generator/src/generators/go/naming.ts
@@ -0,0 +1,45 @@
+// The `naming` stage: the shared printer/naming instance, the package clause, and
+// the collision-free operation identifiers every other stage builds on.
+
+import {
+ type ApiModel,
+ NotSupportedError,
+ type OperationModel,
+ RESERVED_WORDS,
+} from '@redocly/client-generator';
+import { exported, GoPrinter } from '@redocly/client-generator/printers/go';
+
+// One escaping policy for every Go string literal this generator prints.
+export const naming = new GoPrinter();
+
+export const GO = RESERVED_WORDS.go;
+
+/**
+ * The package clause the output declares. Rewriting an invalid name would hide the
+ * publisher's typo behind a package their imports don't mention, so this rejects it.
+ */
+export function goPackageName(configured: string | undefined): string {
+ if (configured === undefined) return 'client';
+ if (!/^[a-z_][a-z0-9_]*$/.test(configured) || GO.has(configured)) {
+ throw new NotSupportedError(
+ `goPackage "${configured}" is not a valid Go package name: use lowercase letters, digits, and underscores, don't start with a digit, and avoid Go keywords.`
+ );
+ }
+ return configured;
+}
+
+/** Every operation with its collision-free exported Go method name. */
+export function goOperationIdents(model: ApiModel): Array<{ op: OperationModel; ident: string }> {
+ const used = new Set();
+ const out: Array<{ op: OperationModel; ident: string }> = [];
+ for (const service of model.services) {
+ for (const op of service.operations) {
+ let ident = exported(op.name);
+ let suffix = 2;
+ while (used.has(ident)) ident = `${exported(op.name)}${suffix++}`;
+ used.add(ident);
+ out.push({ op, ident });
+ }
+ }
+ return out;
+}
diff --git a/packages/client-generator/src/generators/go/operations.ts b/packages/client-generator/src/generators/go/operations.ts
new file mode 100644
index 0000000000..0b2b82c847
--- /dev/null
+++ b/packages/client-generator/src/generators/go/operations.ts
@@ -0,0 +1,292 @@
+// The `operations` stage: one typed request method per operation, plus the
+// argument and envelope-header planning it shares with the pagination wrappers.
+
+import {
+ type ApiModel,
+ type DateType,
+ headerCoerceType,
+ isMultipartBody,
+ jsonSuccessSchema,
+ type OperationModel,
+ type ParamModel,
+ sseResponse,
+ uniqueIdentifiers,
+} from '@redocly/client-generator';
+import { exported, type GoPrinter } from '@redocly/client-generator/printers/go';
+
+import { GO, naming } from './naming.ts';
+import { goType } from './types.ts';
+
+/** A query-value expression formatted to string for url.Values. */
+export function goQueryFormat(expr: string, type: string): string {
+ if (type === 'string') return expr;
+ // Dates serialize in their wire layout, not Go's default String(). A dereferenced
+ // pointer needs parentheses: `*p.Format(…)` would deref Format's result.
+ const receiver = expr.startsWith('*') ? `(${expr})` : expr;
+ if (type === 'time.Time') return `${receiver}.Format(time.RFC3339)`;
+ if (type === 'Date') return `${receiver}.Format("2006-01-02")`;
+ if (type === 'int64') return `strconv.FormatInt(${expr}, 10)`;
+ if (type === 'float64') return `strconv.FormatFloat(${expr}, 'f', -1, 64)`;
+ if (type === 'bool') return `strconv.FormatBool(${expr})`;
+ return `fmt.Sprint(${expr})`;
+}
+
+/**
+ * The argument names a method declares beside its path parameters: the receiver, the
+ * context, the request body, and the query struct.
+ */
+const METHOD_ARG_SLOTS = ['c', 'ctx', 'body', 'params', 'out', 'op'];
+
+/**
+ * Path parameters as Go arguments, uniquely named. A parameter named after one of the
+ * method's own arguments (or a name a description reuses across locations) moves aside as
+ * `id2` — Go rejects a duplicate parameter, and the wire name is untouched either way.
+ */
+export function pathArguments(
+ op: OperationModel,
+ dateType: DateType
+): Array<{ param: ParamModel; go: string; type: string }> {
+ const names = uniqueIdentifiers(
+ op.pathParams.map((param) => param.name),
+ { style: 'camel', reserved: GO, taken: METHOD_ARG_SLOTS }
+ );
+ return op.pathParams.map((param, index) => ({
+ param,
+ go: names[index],
+ type: goType(param.schema, dateType),
+ }));
+}
+
+/** Declared response headers planned for the `Headers` struct: field, wire name, coerce helper. */
+function envelopeHeaderPlan(
+ op: OperationModel,
+ model: ApiModel
+): Array<{ field: string; name: string; goType: string; helper: string }> {
+ const used = new Set();
+ return (op.successResponseHeaders ?? []).map((header) => {
+ const base = exported(header.name);
+ let field = base;
+ let suffix = 2;
+ while (used.has(field)) field = `${base}${suffix++}`;
+ used.add(field);
+ const coerce = headerCoerceType(header.schema, model);
+ const mapping = {
+ integer: { goType: '*int64', helper: 'headerInt64' },
+ number: { goType: '*float64', helper: 'headerFloat64' },
+ boolean: { goType: '*bool', helper: 'headerBool' },
+ string: { goType: '*string', helper: 'headerString' },
+ }[coerce];
+ return { field, name: header.name, ...mapping };
+ });
+}
+
+export function writeGoMethod(
+ printer: GoPrinter,
+ op: OperationModel,
+ ident: string,
+ dateType: DateType,
+ model?: ApiModel,
+ envelope = false
+): void {
+ const pathArgs = pathArguments(op, dateType);
+ const hasParams = op.queryParams.length > 0;
+ const success = jsonSuccessSchema(op);
+ const returnType = success === undefined ? undefined : goType(success, dateType);
+ const headerPlan = envelope ? envelopeHeaderPlan(op, model!) : [];
+ if (envelope) {
+ printer.line(
+ `// ${ident}Headers carries the declared response headers of ${ident}WithHeaders (nil when absent or unparsable).`
+ );
+ printer.block(
+ `type ${ident}Headers struct {`,
+ () => {
+ for (const planned of headerPlan) printer.line(`${planned.field} ${planned.goType}`);
+ },
+ '}'
+ );
+ printer.blank();
+ }
+ const args = [
+ 'ctx context.Context',
+ ...pathArgs.map(({ go, type }) => `${go} ${type}`),
+ ...(op.requestBody ? [`body ${goType(op.requestBody.schema, dateType)}`] : []),
+ ...(hasParams ? [`params *${ident}Params`] : []),
+ ];
+ const sse = sseResponse(op);
+ const returns = envelope
+ ? returnType === undefined
+ ? `(${ident}Headers, error)`
+ : `(${returnType}, ${ident}Headers, error)`
+ : sse !== undefined
+ ? 'func(yield func(ServerSentEvent, error) bool)'
+ : returnType === undefined
+ ? 'error'
+ : `(${returnType}, error)`;
+ const fail = (errExpr: string) =>
+ envelope
+ ? returnType === undefined
+ ? `return headers, ${errExpr}`
+ : `return out, headers, ${errExpr}`
+ : returnType === undefined
+ ? `return ${errExpr}`
+ : `return out, ${errExpr}`;
+ const funcName = envelope ? `${ident}WithHeaders` : ident;
+ printer.doc(
+ funcName,
+ envelope ? `Like ${ident}, also returning the declared response headers.` : op.summary
+ );
+ printer.block(
+ `func (c *Client) ${funcName}(${args.join(', ')}) ${returns} {`,
+ () => {
+ if (sse === undefined && returnType !== undefined) printer.line(`var out ${returnType}`);
+ if (envelope) printer.line(`var headers ${ident}Headers`);
+ printer.line(`op := operations[${naming.string(op.specName ?? op.name)}]`);
+ printer.line('authHeaders, query := resolveAuth(op.Security, c.config.Auth)');
+ if (hasParams) {
+ printer.block(
+ 'if params != nil {',
+ () => {
+ for (const param of op.queryParams) {
+ const field = exported(param.name);
+ printer.block(
+ `if params.${field} != nil {`,
+ () => {
+ // An array repeats the key per element (OpenAPI `form` + `explode`, the
+ // default — and what the TS runtime sends). `fmt.Sprint` of a slice
+ // would put `[a b]` on the wire as one value.
+ if (param.schema.kind === 'array') {
+ const elementType = goType(param.schema.items, dateType);
+ printer.block(
+ `for _, item := range *params.${field} {`,
+ () => {
+ printer.line(
+ `query.Add(${naming.string(param.name)}, ${goQueryFormat('item', elementType)})`
+ );
+ },
+ '}'
+ );
+ } else {
+ printer.line(
+ `query.Set(${naming.string(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema, dateType))})`
+ );
+ }
+ },
+ '}'
+ );
+ }
+ },
+ '}'
+ );
+ }
+ const pathDict = pathArgs
+ .map(({ param, go, type }) => `${naming.string(param.name)}: ${goQueryFormat(go, type)}`)
+ .join(', ');
+ printer.line(
+ `requestURL := buildURL(c.config.ServerURL, op.Path, map[string]string{${pathDict}})`
+ );
+ if (sse !== undefined) {
+ printer.block(
+ 'open := func(extraHeaders map[string]string) (*http.Response, error) {',
+ () => {
+ printer.line('merged := map[string]string{}');
+ printer.block(
+ 'for key, value := range authHeaders {',
+ () => {
+ printer.line('merged[key] = value');
+ },
+ '}'
+ );
+ printer.block(
+ 'for key, value := range extraHeaders {',
+ () => {
+ printer.line('merged[key] = value');
+ },
+ '}'
+ );
+ printer.line(
+ 'return send(ctx, &c.config, requestSpec{OperationID: op.ID, Method: op.Method, URL: requestURL, Headers: merged, Query: query})'
+ );
+ },
+ '}'
+ );
+ printer.line(
+ `return iterSSE(open, ${sse.schema !== undefined && sse.schema.kind !== 'unknown'})`
+ );
+ return;
+ }
+ const specFields = [
+ 'OperationID: op.ID',
+ 'Method: op.Method',
+ 'URL: requestURL',
+ 'Headers: authHeaders',
+ 'Query: query',
+ ];
+ if (op.requestBody && isMultipartBody(op)) {
+ printer.line('contentType, reader, err := toMultipart(body)');
+ printer.block(
+ 'if err != nil {',
+ () => {
+ printer.line(fail('err'));
+ },
+ '}'
+ );
+ specFields.push('Body: reader');
+ specFields.push('ContentType: contentType');
+ } else if (op.requestBody) {
+ printer.line('payload, err := json.Marshal(body)');
+ printer.block(
+ 'if err != nil {',
+ () => {
+ printer.line(fail('err'));
+ },
+ '}'
+ );
+ specFields.push('Body: bytes.NewReader(payload)');
+ specFields.push(`ContentType: ${naming.string(op.requestBody.contentType)}`);
+ }
+ printer.line(`resp, err := send(ctx, &c.config, requestSpec{${specFields.join(', ')}})`);
+ printer.block(
+ 'if err != nil {',
+ () => {
+ printer.line(fail('err'));
+ },
+ '}'
+ );
+ printer.block(
+ 'if resp.StatusCode >= 400 {',
+ () => {
+ printer.line(fail('apiErrorFrom(resp, requestURL)'));
+ },
+ '}'
+ );
+ if (envelope) {
+ printer.block(
+ `if err := decodeJSON(resp, ${returnType === undefined ? 'nil' : '&out'}); err != nil {`,
+ () => {
+ printer.line(fail('err'));
+ },
+ '}'
+ );
+ for (const planned of headerPlan) {
+ printer.line(
+ `headers.${planned.field} = ${planned.helper}(resp.Header, ${naming.string(planned.name)})`
+ );
+ }
+ printer.line(returnType === undefined ? 'return headers, nil' : 'return out, headers, nil');
+ } else if (returnType === undefined) {
+ printer.line('return decodeJSON(resp, nil)');
+ } else {
+ printer.block(
+ 'if err := decodeJSON(resp, &out); err != nil {',
+ () => {
+ printer.line('return out, err');
+ },
+ '}'
+ );
+ printer.line('return out, nil');
+ }
+ },
+ '}'
+ );
+ printer.blank();
+}
diff --git a/packages/client-generator/src/generators/go/pagination.ts b/packages/client-generator/src/generators/go/pagination.ts
new file mode 100644
index 0000000000..0604ff9160
--- /dev/null
+++ b/packages/client-generator/src/generators/go/pagination.ts
@@ -0,0 +1,190 @@
+// The `pagination` stage: the `Pages` / `Items` yield-func iterators.
+
+import { type DateType, type OperationModel } from '@redocly/client-generator';
+import { exported, type GoPrinter } from '@redocly/client-generator/printers/go';
+
+import { naming } from './naming.ts';
+import { goQueryFormat, pathArguments } from './operations.ts';
+import { goType } from './types.ts';
+
+/** `Pages` / `Items` iterators over the runtime's `iterPages`, hydrated via `reencode`. */
+export function writeGoPaginationWrappers(
+ printer: GoPrinter,
+ op: OperationModel,
+ ident: string,
+ dateType: DateType,
+ pageType: string,
+ itemType: string
+): void {
+ const pathArgs = pathArguments(op, dateType);
+ const hasParams = op.queryParams.length > 0;
+ const args = [
+ 'ctx context.Context',
+ ...pathArgs.map(({ go, type }) => `${go} ${type}`),
+ ...(hasParams ? [`params *${ident}Params`] : []),
+ ].join(', ');
+
+ const writeCallClosure = () => {
+ printer.line(`op := operations[${naming.string(op.specName ?? op.name)}]`);
+ printer.line('base := url.Values{}');
+ if (hasParams) {
+ printer.block(
+ 'if params != nil {',
+ () => {
+ for (const param of op.queryParams) {
+ const field = exported(param.name);
+ printer.block(
+ `if params.${field} != nil {`,
+ () => {
+ printer.line(
+ `base.Set(${naming.string(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema, dateType))})`
+ );
+ },
+ '}'
+ );
+ }
+ },
+ '}'
+ );
+ }
+ printer.block(
+ 'call := func(pageParams url.Values) (any, *http.Response, error) {',
+ () => {
+ printer.line('authHeaders, query := resolveAuth(op.Security, c.config.Auth)');
+ printer.block(
+ 'for key, values := range pageParams {',
+ () => {
+ printer.block(
+ 'for _, value := range values {',
+ () => {
+ printer.line('query.Set(key, value)');
+ },
+ '}'
+ );
+ },
+ '}'
+ );
+ const pathDict = pathArgs
+ .map(({ param, go, type }) => `${naming.string(param.name)}: ${goQueryFormat(go, type)}`)
+ .join(', ');
+ printer.line(
+ `requestURL := buildURL(c.config.ServerURL, op.Path, map[string]string{${pathDict}})`
+ );
+ printer.line(
+ 'resp, err := send(ctx, &c.config, requestSpec{OperationID: op.ID, Method: op.Method, URL: requestURL, Headers: authHeaders, Query: query})'
+ );
+ printer.block(
+ 'if err != nil {',
+ () => {
+ printer.line('return nil, nil, err');
+ },
+ '}'
+ );
+ printer.block(
+ 'if resp.StatusCode >= 400 {',
+ () => {
+ printer.line('return nil, resp, apiErrorFrom(resp, requestURL)');
+ },
+ '}'
+ );
+ printer.line('var raw any');
+ printer.block(
+ 'if err := decodeJSON(resp, &raw); err != nil {',
+ () => {
+ printer.line('return nil, resp, err');
+ },
+ '}'
+ );
+ printer.line('return raw, resp, nil');
+ },
+ '}'
+ );
+ printer.line('pages := iterPages(call, *op.Pagination, base)');
+ };
+
+ printer.line(
+ `// ${ident}Pages iterates ${ident} response pages; use with \`for page, err := range\`.`
+ );
+ printer.block(
+ `func (c *Client) ${ident}Pages(${args}) func(yield func(${pageType}, error) bool) {`,
+ () => {
+ writeCallClosure();
+ printer.block(
+ `return func(yield func(${pageType}, error) bool) {`,
+ () => {
+ printer.block(
+ 'pages(func(raw any, err error) bool {',
+ () => {
+ printer.line(`var page ${pageType}`);
+ printer.block(
+ 'if err == nil {',
+ () => {
+ printer.line('err = reencode(raw, &page)');
+ },
+ '}'
+ );
+ printer.line('return yield(page, err)');
+ },
+ '})'
+ );
+ },
+ '}'
+ );
+ },
+ '}'
+ );
+ printer.blank();
+
+ printer.line(`// ${ident}Items iterates the items of every ${ident} page.`);
+ printer.block(
+ `func (c *Client) ${ident}Items(${args}) func(yield func(${itemType}, error) bool) {`,
+ () => {
+ writeCallClosure();
+ printer.block(
+ `return func(yield func(${itemType}, error) bool) {`,
+ () => {
+ printer.block(
+ 'pages(func(raw any, err error) bool {',
+ () => {
+ printer.block(
+ 'if err != nil {',
+ () => {
+ printer.line(`var zero ${itemType}`);
+ printer.line('return yield(zero, err)');
+ },
+ '}'
+ );
+ printer.line('pageItems, _ := resolvePointer(raw, op.Pagination.Items).([]any)');
+ printer.block(
+ 'for _, item := range pageItems {',
+ () => {
+ printer.line(`var typed ${itemType}`);
+ printer.block(
+ 'if err := reencode(item, &typed); err != nil {',
+ () => {
+ printer.line('return yield(typed, err)');
+ },
+ '}'
+ );
+ printer.block(
+ 'if !yield(typed, nil) {',
+ () => {
+ printer.line('return false');
+ },
+ '}'
+ );
+ },
+ '}'
+ );
+ printer.line('return true');
+ },
+ '})'
+ );
+ },
+ '}'
+ );
+ },
+ '}'
+ );
+ printer.blank();
+}
diff --git a/packages/client-generator/runtime/go/go.mod b/packages/client-generator/src/generators/go/runtime/go.mod
similarity index 100%
rename from packages/client-generator/runtime/go/go.mod
rename to packages/client-generator/src/generators/go/runtime/go.mod
diff --git a/packages/client-generator/runtime/go/runtime.go b/packages/client-generator/src/generators/go/runtime/runtime.go
similarity index 100%
rename from packages/client-generator/runtime/go/runtime.go
rename to packages/client-generator/src/generators/go/runtime/runtime.go
diff --git a/packages/client-generator/src/generators/go/types.ts b/packages/client-generator/src/generators/go/types.ts
new file mode 100644
index 0000000000..62899eac65
--- /dev/null
+++ b/packages/client-generator/src/generators/go/types.ts
@@ -0,0 +1,56 @@
+// The `types` stage: the Go type annotation for a schema.
+
+import {
+ type DateType,
+ isNullable,
+ type SchemaModel,
+ unwrapNullable,
+} from '@redocly/client-generator';
+import { exported } from '@redocly/client-generator/printers/go';
+
+/** The Go type for a schema; `required=false` optionals become pointers at the field site. */
+export function goType(schema: SchemaModel, dateType: DateType = 'string'): string {
+ if (isNullable(schema)) {
+ const inner = goType(unwrapNullable(schema), dateType);
+ return inner.startsWith('*') || inner === 'any' ? inner : `*${inner}`;
+ }
+ switch (schema.kind) {
+ case 'scalar':
+ // Under `dateType: Date`, a date-time is a time.Time (encoding/json handles
+ // RFC 3339 natively) and a bare date is the runtime's `Date` wrapper.
+ if (dateType === 'Date' && schema.scalar === 'string') {
+ if (schema.metadata?.format === 'date-time') return 'time.Time';
+ if (schema.metadata?.format === 'date') return 'Date';
+ }
+ return { string: 'string', integer: 'int64', number: 'float64', boolean: 'bool' }[
+ schema.scalar
+ ];
+ case 'array':
+ return `[]${goType(schema.items, dateType)}`;
+ case 'record':
+ return `map[string]${goType(schema.value, dateType)}`;
+ case 'ref':
+ return exported(schema.name);
+ case 'literal':
+ return typeof schema.value === 'string'
+ ? 'string'
+ : typeof schema.value === 'boolean'
+ ? 'bool'
+ : 'float64';
+ case 'enum':
+ // Anonymous (inline) enums keep the wire scalar; only NAMED enums get types.
+ return { string: 'string', integer: 'int64', number: 'float64', boolean: 'bool' }[
+ schema.scalar
+ ];
+ case 'omit':
+ // Go has no Omit; the base struct is the honest annotation (readOnly
+ // fields are server-managed and simply omitted from requests).
+ return exported(schema.base);
+ case 'union':
+ case 'null':
+ case 'object':
+ case 'intersection':
+ case 'unknown':
+ return 'any';
+ }
+}
diff --git a/packages/client-generator/src/generators/index.ts b/packages/client-generator/src/generators/index.ts
index c101c6fec7..9391930a3d 100644
--- a/packages/client-generator/src/generators/index.ts
+++ b/packages/client-generator/src/generators/index.ts
@@ -1,4 +1,3 @@
-import type { EmitOptions } from '../emitters/emit-options.js';
import { cliDocs, cliGenerator, cliSample } from './cli/index.js';
import { goDocs, goGenerator, goSample } from './go/index.js';
import { BUILTIN_META, validateSelection, type BuiltinMeta } from './meta.js';
@@ -8,7 +7,7 @@ import { pythonDocs, pythonGenerator, pythonSample } from './python/index.js';
import { swrGenerator } from './swr/index.js';
import { tanstackQueryGenerator } from './tanstack-query/index.js';
import { transformersGenerator } from './transformers/index.js';
-import type { GeneratorDescriptor, GeneratorName, OutputMode } from './types.js';
+import type { EmitOptions, GeneratorDescriptor, GeneratorName, OutputMode } from './types.js';
import { typescriptDocs, typescriptGenerator, typescriptSample } from './typescript/index.js';
import { zodGenerator } from './zod/index.js';
diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts
index 51bf2b328d..6bdffe67c2 100644
--- a/packages/client-generator/src/generators/meta.ts
+++ b/packages/client-generator/src/generators/meta.ts
@@ -5,9 +5,8 @@
import { logger } from '@redocly/openapi-core';
-import type { EmitOptions } from '../emitters/emit-options.js';
import { NotSupportedError } from '../errors.js';
-import type { GeneratorDescriptor, GeneratorName, OutputMode } from './types.js';
+import type { EmitOptions, GeneratorDescriptor, GeneratorName, OutputMode } from './types.js';
export type BuiltinMeta = Omit & {
load: () => Promise>;
@@ -151,7 +150,6 @@ export function validateSelection(
}
const errorMode = emit.errorMode ?? 'throw';
const dateType = emit.dateType ?? 'string';
- const runtime = emit.runtime ?? 'inline';
for (const name of names) {
const descriptor = registry.get(name);
if (!descriptor) {
@@ -175,11 +173,6 @@ export function validateSelection(
`The "${name}" generator requires --date-type ${descriptor.dateTypes.join(' or ')} (got "${dateType}") so the runtime values match the generated types.`
);
}
- if (descriptor.runtimes && !descriptor.runtimes.includes(runtime)) {
- throw new NotSupportedError(
- `The "${name}" generator does not support runtime "${runtime}" (supported: ${descriptor.runtimes.join(', ')}).`
- );
- }
// An option this generator can't apply is announced, not silently dropped. Only an
// EXPLICIT value warns — defaults would nag every run.
const chosen: Record = { ...emit, outputMode };
diff --git a/packages/client-generator/src/emitters/__tests__/faker.test.ts b/packages/client-generator/src/generators/mock/__tests__/faker.test.ts
similarity index 98%
rename from packages/client-generator/src/emitters/__tests__/faker.test.ts
rename to packages/client-generator/src/generators/mock/__tests__/faker.test.ts
index ea5a167fe3..4add9382bb 100644
--- a/packages/client-generator/src/emitters/__tests__/faker.test.ts
+++ b/packages/client-generator/src/generators/mock/__tests__/faker.test.ts
@@ -1,6 +1,6 @@
-import type { NamedSchemaModel, SchemaModel } from '../../intermediate-representation/model.js';
+import type { NamedSchemaModel, SchemaModel } from '../../../intermediate-representation/model.js';
import { fakerExpression } from '../faker.js';
-import { renderMockValue } from '../mock-value.js';
+import { renderMockValue } from '../values.js';
/** Emit `schema`'s faker expression and render it to source for substring assertions. */
function emit(
diff --git a/packages/client-generator/src/emitters/__tests__/mock.test.ts b/packages/client-generator/src/generators/mock/__tests__/render.test.ts
similarity index 99%
rename from packages/client-generator/src/emitters/__tests__/mock.test.ts
rename to packages/client-generator/src/generators/mock/__tests__/render.test.ts
index d653803eb9..5f5a09f345 100644
--- a/packages/client-generator/src/emitters/__tests__/mock.test.ts
+++ b/packages/client-generator/src/generators/mock/__tests__/render.test.ts
@@ -1,5 +1,5 @@
-import { renderMockModule } from '../mock.js';
-import { apiModel, namedSchema, operation, param } from './fixtures.js';
+import { apiModel, namedSchema, operation, param } from '../../../__tests__/fixtures.js';
+import { renderMockModule } from '../render.js';
describe('renderMockModule', () => {
it('emits the msw import, a factory per named schema, and a handlers array', () => {
diff --git a/packages/client-generator/src/emitters/__tests__/sample.test.ts b/packages/client-generator/src/generators/mock/__tests__/sample.test.ts
similarity index 99%
rename from packages/client-generator/src/emitters/__tests__/sample.test.ts
rename to packages/client-generator/src/generators/mock/__tests__/sample.test.ts
index 92e5b8f194..c7a121763c 100644
--- a/packages/client-generator/src/emitters/__tests__/sample.test.ts
+++ b/packages/client-generator/src/generators/mock/__tests__/sample.test.ts
@@ -1,4 +1,4 @@
-import type { NamedSchemaModel, SchemaModel } from '../../intermediate-representation/model.js';
+import type { NamedSchemaModel, SchemaModel } from '../../../intermediate-representation/model.js';
import { sampleValue, SampleExpression } from '../sample.js';
describe('sampleValue', () => {
diff --git a/packages/client-generator/src/emitters/faker.ts b/packages/client-generator/src/generators/mock/faker.ts
similarity index 95%
rename from packages/client-generator/src/emitters/faker.ts
rename to packages/client-generator/src/generators/mock/faker.ts
index 0145663807..6c9e604325 100644
--- a/packages/client-generator/src/emitters/faker.ts
+++ b/packages/client-generator/src/generators/mock/faker.ts
@@ -1,6 +1,6 @@
// Builds the body value for a faker-mode mock factory: a tree of
// `@faker-js/faker` call expressions that produce realistic — and, with a seed,
-// reproducible — data. Structurally mirrors `emitters/sample.ts`'s `walk` (same
+// reproducible — data. Structurally mirrors `./sample.ts`'s `walk` (same
// recursion + same visited-set cycle guard), but yields faker calls instead of a
// static value. Nested refs are INLINED under the same cycle guard (never
// `create[()` calls), so a cyclic schema terminates with `null` at the cycle
@@ -9,16 +9,17 @@
// `mockData` without touching call sites; `@faker-js/faker` becomes their
// dev-dep while the real client stays dependency-free.
-import type {
- NamedSchemaModel,
- ScalarKind,
- SchemaMetadata,
- SchemaModel,
-} from '../intermediate-representation/model.js';
-import { expr, isObjectValue, type MockEntry, type MockValue, objectValue } from './mock-value.js';
-import { splitIntersection } from './sample.js';
-import { codeLiteral } from './ts-literal.js';
-import type { DateType } from './types.js';
+import {
+ type DateType,
+ type NamedSchemaModel,
+ type ScalarKind,
+ type SchemaMetadata,
+ type SchemaModel,
+} from '@redocly/client-generator';
+import { codeLiteral } from '@redocly/client-generator/printers/typescript';
+
+import { splitIntersection } from './sample.ts';
+import { expr, isObjectValue, type MockEntry, type MockValue, objectValue } from './values.ts';
/** The faker-call value for an IR schema. Refs resolve against `schemas`;
* recursion is cut with a visited-set (`null` at the cycle). `dateType` mirrors
@@ -39,7 +40,7 @@ export function fakerExpression(
/**
* Sentinel returned by `walk` when a `$ref` re-enters a name already on the stack.
* Containers turn it into the type-correct empty value for their position — an array
- * to `[]`, a record to `{}`, an optional property to omission — mirroring `emitters/sample.ts`
+ * to `[]`, a record to `{}`, an optional property to omission — mirroring `./sample.ts`
* so a recursive schema yields a faker tree that still satisfies its non-nullable type.
* Only a required, non-container self-reference (an uninhabitable schema) degrades to null.
*/
diff --git a/packages/client-generator/src/generators/mock/index.ts b/packages/client-generator/src/generators/mock/index.ts
index f139fff4ec..c124c85bd1 100644
--- a/packages/client-generator/src/generators/mock/index.ts
+++ b/packages/client-generator/src/generators/mock/index.ts
@@ -1,9 +1,7 @@
+import type { Generator } from '@redocly/client-generator';
import { join } from 'node:path';
-import { HEADER } from '../../emitters/emit-options.js';
-import { renderMockModule } from '../../emitters/mock.js';
-import { anchor } from '../anchor.js';
-import type { Generator } from '../types.js';
+import { renderMockModule } from './render.ts';
/**
* The mock generator: a standalone `.mocks.ts` module of MSW handlers and
@@ -11,14 +9,16 @@ import type { Generator } from '../types.js';
* sdk client stays dependency-free. Output-mode-agnostic in v1 — one module beside
* the client. Emits nothing when there are no operations.
*/
-export const mockGenerator: Generator = ({ model, outputPath, emit }) => {
- const { dir, stem } = anchor(outputPath);
+export const mockGenerator: Generator = ({ model, output, banner, emit }) => {
+ const header = banner.map((line) => `// ${line}`).join('\n');
const content = renderMockModule(model, {
- sdkModule: `./${stem}.${emit.importExt ?? 'js'}`,
+ sdkModule: `./${output.stem}.${emit.importExt ?? 'js'}`,
dateType: emit.dateType,
mockData: emit.mockData,
mockSeed: emit.mockSeed,
});
if (content === '') return [];
- return [{ path: join(dir, `${stem}.mocks.ts`), content: `${HEADER}\n\n${content}` }];
+ return [
+ { path: join(output.dir, `${output.stem}.mocks.ts`), content: `${header}\n\n${content}` },
+ ];
};
diff --git a/packages/client-generator/src/emitters/mock.ts b/packages/client-generator/src/generators/mock/render.ts
similarity index 97%
rename from packages/client-generator/src/emitters/mock.ts
rename to packages/client-generator/src/generators/mock/render.ts
index cb9f6c8770..c6f50a247f 100644
--- a/packages/client-generator/src/emitters/mock.ts
+++ b/packages/client-generator/src/generators/mock/render.ts
@@ -5,18 +5,24 @@
// literals — source-text templates — so the generated module depends only on
// `msw`; the real client stays zero-dependency.
-import { isPlainObject } from '@redocly/openapi-core';
-
import {
allOperations,
type ApiModel,
+ type DateType,
type NamedSchemaModel,
type OperationModel,
type ResponseBodyModel,
type SchemaModel,
-} from '../intermediate-representation/model.js';
-import { fakerExpression } from './faker.js';
-import { isIdentifier } from './identifier.js';
+} from '@redocly/client-generator';
+import {
+ codeLiteral,
+ isIdentifier,
+ pascalCase,
+} from '@redocly/client-generator/printers/typescript';
+import { isPlainObject } from '@redocly/openapi-core';
+
+import { fakerExpression } from './faker.ts';
+import { sampleValue, SampleExpression } from './sample.ts';
import {
expr,
isObjectValue,
@@ -24,11 +30,7 @@ import {
objectValue,
renderMockValue,
spreadInto,
-} from './mock-value.js';
-import { sampleValue, SampleExpression } from './sample.js';
-import { pascalCase } from './support.js';
-import { codeLiteral } from './ts-literal.js';
-import type { DateType } from './types.js';
+} from './values.ts';
const INDENT = ' ';
diff --git a/packages/client-generator/src/emitters/sample.ts b/packages/client-generator/src/generators/mock/sample.ts
similarity index 98%
rename from packages/client-generator/src/emitters/sample.ts
rename to packages/client-generator/src/generators/mock/sample.ts
index fbd74b81d2..217bfcd89b 100644
--- a/packages/client-generator/src/emitters/sample.ts
+++ b/packages/client-generator/src/generators/mock/sample.ts
@@ -1,13 +1,12 @@
+import {
+ type DateType,
+ type NamedSchemaModel,
+ type ScalarKind,
+ type SchemaMetadata,
+ type SchemaModel,
+} from '@redocly/client-generator';
import { isPlainObject } from '@redocly/openapi-core';
-import type {
- NamedSchemaModel,
- ScalarKind,
- SchemaMetadata,
- SchemaModel,
-} from '../intermediate-representation/model.js';
-import type { DateType } from './types.js';
-
/** A sampled value the emitter must print as a raw TS expression rather than a JSON
* literal — e.g. a `format: binary` field, whose generated type is `Blob`. The `code`
* strings are generator-authored constants (never spec-derived), so emitting them
diff --git a/packages/client-generator/src/emitters/mock-value.ts b/packages/client-generator/src/generators/mock/values.ts
similarity index 96%
rename from packages/client-generator/src/emitters/mock-value.ts
rename to packages/client-generator/src/generators/mock/values.ts
index b8412c17c2..6c430ca8da 100644
--- a/packages/client-generator/src/emitters/mock-value.ts
+++ b/packages/client-generator/src/generators/mock/values.ts
@@ -2,8 +2,7 @@
// (for intersection merging and `...overrides` spreading) until the final render,
// where indentation is threaded. Deliberately tiny.
-import { safeIdent } from './identifier.js';
-import { sanitizeCodeString } from './ts-literal.js';
+import { safeIdent, sanitizeCodeString } from '@redocly/client-generator/printers/typescript';
export type MockEntry = { key: string; value: MockValue } | { spread: string };
diff --git a/packages/client-generator/src/generators/php/AGENTS.md b/packages/client-generator/src/generators/php/AGENTS.md
index 84c69dc9d4..bf808298ea 100644
--- a/packages/client-generator/src/generators/php/AGENTS.md
+++ b/packages/client-generator/src/generators/php/AGENTS.md
@@ -73,8 +73,10 @@ $idempotencyKey` on mutating methods.
- **Parity surface:** auth, retries with `Retry-After` + jittered backoff, per-attempt
curl timeouts, middleware callables, pagination (`Pages()` / `Items()` as
`\Generator`s), SSE (`iterSse` over a curl_multi pump), multipart.
-- The runtime is hand-written in `runtime/php/runtime.php` (`php -l`-clean) and embedded
+- The runtime is hand-written in `runtime/runtime.php` in this folder (`php -l`-clean) and embedded
at prepare time. `curl_close` is never called (deprecated since PHP 8.5, no-op since 8.0).
+ Under `--runtime module` it is written as a `runtime.php` the client `require_once`s,
+ with its namespace rewritten to the client's so one namespace spans both files.
- Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise.
## Migrating from a service-based SDK
@@ -102,7 +104,7 @@ $idempotencyKey` on mutating methods.
## The modify loop
1. Edit this skill: state the new behavior or decision.
-2. Change `index.ts` (and `runtime/php/runtime.php` for runtime behavior; `php -l` it,
+2. Change `index.ts` (and `runtime/runtime.php` for runtime behavior; `php -l` it,
then `npm run prepare -w @redocly/client-generator`).
3. Verify: `npm run compile`, then
`VITEST_SUITE=unit npx vitest run packages/client-generator/src/generators/__tests__/php.test.ts`
diff --git a/packages/client-generator/src/generators/php/client.ts b/packages/client-generator/src/generators/php/client.ts
new file mode 100644
index 0000000000..d4c11bdbc7
--- /dev/null
+++ b/packages/client-generator/src/generators/php/client.ts
@@ -0,0 +1,59 @@
+// The `client` stage: the `Servers` helper class of one static method per
+// declared server.
+
+import {
+ type ApiModel,
+ identifierFor,
+ type ServerModel,
+ serverUrlParts,
+} from '@redocly/client-generator';
+import type { PhpPrinter } from '@redocly/client-generator/printers/php';
+
+import { PHP, phpString, propertyName } from './naming.ts';
+
+/** The server URL as a PHP expression: literals concatenated with declared-variable args. */
+function serverUrlExpression(server: ServerModel): string {
+ const parts = serverUrlParts(server).map((part) =>
+ part.kind === 'literal' ? phpString(part.value) : `${'$'}${propertyName(part.name)}`
+ );
+ return parts.join(' . ');
+}
+
+/** One static method per declared server; server variables become named string arguments. */
+export function writeServers(printer: PhpPrinter, model: ApiModel): void {
+ const servers = model.servers ?? [];
+ if (servers.length === 0) return;
+ const usedNames = new Set();
+ printer.line(
+ '/** The declared servers; variables default to the values from the description. */'
+ );
+ printer.line('final class Servers');
+ printer.block(
+ '{',
+ () => {
+ servers.forEach((server, index) => {
+ let name = identifierFor(server.description ?? `server${index + 1}`, {
+ style: 'camel',
+ reserved: PHP,
+ });
+ if (usedNames.has(name)) name = `${name}${index + 1}`;
+ usedNames.add(name);
+ const params = server.variables.map(
+ (variable) =>
+ `string ${'$'}${propertyName(variable.name)} = ${phpString(variable.default)}`
+ );
+ if (index > 0) printer.blank();
+ printer.line(`public static function ${name}(${params.join(', ')}): string`);
+ printer.block(
+ '{',
+ () => {
+ printer.line(`return ${serverUrlExpression(server)};`);
+ },
+ '}'
+ );
+ });
+ },
+ '}'
+ );
+ printer.blank();
+}
diff --git a/packages/client-generator/src/generators/php/descriptor.ts b/packages/client-generator/src/generators/php/descriptor.ts
new file mode 100644
index 0000000000..7115ca65c0
--- /dev/null
+++ b/packages/client-generator/src/generators/php/descriptor.ts
@@ -0,0 +1,54 @@
+// The `descriptor` stage: the operations-table array literals — security
+// OR-alternatives, the pagination spec, and envelope-header coerce specs.
+
+import {
+ type ApiModel,
+ headerCoerceType,
+ identifierFor,
+ type NeutralPaginationRule,
+ type OperationModel,
+ securityRequirements,
+} from '@redocly/client-generator';
+
+import { PHP, phpString } from './naming.ts';
+
+/** Security literal for the operations table, denormalized from the model's schemes. */
+export function phpSecurityLiteral(op: OperationModel, model: ApiModel): string | undefined {
+ const alternatives = securityRequirements(op, model).map((alternative) => {
+ const specs = alternative.map((spec) =>
+ spec.kind === 'apiKey'
+ ? `['kind' => 'apiKey', 'scheme' => ${phpString(spec.scheme)}, 'name' => ${phpString(spec.name)}, 'in' => ${phpString(spec.in)}]`
+ : `['kind' => ${phpString(spec.kind)}, 'scheme' => ${phpString(spec.scheme)}]`
+ );
+ return `[${specs.join(', ')}]`;
+ });
+ if (alternatives.length === 0) return undefined;
+ return `[${alternatives.join(', ')}]`;
+}
+
+export function phpPaginationLiteral(rule: NeutralPaginationRule): string {
+ const fields = [
+ `'style' => ${phpString(rule.style)}`,
+ ...(rule.param !== undefined ? [`'param' => ${phpString(rule.param)}`] : []),
+ ...(rule.nextCursor !== undefined ? [`'nextCursor' => ${phpString(rule.nextCursor)}`] : []),
+ ...(rule.hasMore !== undefined ? [`'hasMore' => ${phpString(rule.hasMore)}`] : []),
+ ...(rule.limitParam !== undefined ? [`'limitParam' => ${phpString(rule.limitParam)}`] : []),
+ ...(rule.items !== undefined ? [`'items' => ${phpString(rule.items)}`] : []),
+ ];
+ return `[${fields.join(', ')}]`;
+}
+
+/** Declared response headers as runtime coerce specs: `[wire name, camelCase key, type]`. */
+export function envelopeHeaderSpecs(op: OperationModel, model: ApiModel): string {
+ const used = new Set();
+ const specs = (op.successResponseHeaders ?? []).map((header) => {
+ let key = identifierFor(header.name, { style: 'camel', reserved: PHP });
+ let suffix = 2;
+ while (used.has(key))
+ key = `${identifierFor(header.name, { style: 'camel', reserved: PHP })}_${suffix++}`;
+ used.add(key);
+ const type = headerCoerceType(header.schema, model);
+ return `[${phpString(header.name)}, ${phpString(key)}, ${phpString(type)}]`;
+ });
+ return `[${specs.join(', ')}]`;
+}
diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts
index f6273fb1e5..ad52a3bfa7 100644
--- a/packages/client-generator/src/generators/php/index.ts
+++ b/packages/client-generator/src/generators/php/index.ts
@@ -6,920 +6,30 @@
// embedded runtime. Exceptions are the error mode (`errorMode` does not apply).
import {
- Printer,
- docText,
- discriminatorCases,
- enumValues,
- flattenAllOf,
- headerCoerceType,
+ type CodeSample,
+ type Generator,
identifierFor,
- uniqueIdentifiers,
- isNullable,
- paginationRuleFor,
- renderReferencePage,
- RESERVED_WORDS,
- schemaAtPointer,
- unwrapNullable,
+ jsonSuccessSchema,
type NeutralPaginationRule,
- type DateType,
-} from '../../authoring/index.js';
-import { PHP_RUNTIME_SOURCE } from '../../emitters/php-runtime-sources.js';
-import type {
- ApiModel,
- OperationModel,
- PropertyModel,
- SchemaModel,
- ServerModel,
-} from '../../intermediate-representation/model.js';
-import type { CodeSample, Generator, SampleContext } from '../types.js';
-
-const PHP = RESERVED_WORDS.php;
-
-function className(name: string): string {
- return identifierFor(name, { style: 'pascal', reserved: PHP });
-}
-
-function propertyName(name: string): string {
- return identifierFor(name, { style: 'camel', reserved: PHP });
-}
-
-/** `'…'` with backslashes and quotes escaped — safe for any spec-supplied text. */
-function phpString(value: string): string {
- return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
-}
-
-/** Follow ref chains through the named schemas (cycle-guarded). */
-function deref(schema: SchemaModel, model: ApiModel): SchemaModel | undefined {
- const seen = new Set();
- let current = schema;
- while (current.kind === 'ref') {
- const { name } = current;
- if (seen.has(name)) return undefined;
- seen.add(name);
- const named = model.schemas.find((candidate) => candidate.name === name);
- if (named === undefined) return undefined;
- current = named.schema;
- }
- return current;
-}
-
-/** What a named schema renders as: a class, a native enum, or nothing (alias). */
-function classify(name: string, model: ApiModel): 'class' | 'enum' | 'other' {
- const named = model.schemas.find((candidate) => candidate.name === name);
- if (named === undefined) return 'other';
- const schema = named.schema;
- const asEnum = enumValues(schema);
- if (asEnum !== undefined && (asEnum.scalar === 'string' || asEnum.scalar === 'integer')) {
- return 'enum';
- }
- if (
- (schema.kind === 'object' || schema.kind === 'intersection') &&
- flattenAllOf(schema, model) !== undefined
- ) {
- return 'class';
- }
- return 'other';
-}
-
-/** The PHP type declaration for a schema (arrays and unions widen to array/mixed). */
-export function phpType(
- schema: SchemaModel,
- model: ApiModel,
- dateType: DateType = 'string'
-): string {
- if (isNullable(schema)) {
- const inner = phpType(unwrapNullable(schema), model, dateType);
- return phpNullable(inner);
- }
- switch (schema.kind) {
- case 'scalar':
- // Under `dateType: Date`, date and date-time become DateTimeImmutable — PHP's
- // immutable date object parses and formats both wire shapes.
- if (dateType === 'Date' && schema.scalar === 'string' && isDateFormat(schema)) {
- return '\\DateTimeImmutable';
- }
- return { string: 'string', integer: 'int', number: 'float', boolean: 'bool' }[schema.scalar];
- case 'array':
- case 'record':
- return 'array';
- case 'ref': {
- const kind = classify(schema.name, model);
- if (kind === 'class' || kind === 'enum') return className(schema.name);
- const target = deref(schema, model);
- return target === undefined ? 'mixed' : phpType(target, model, dateType);
- }
- case 'enum':
- // Anonymous (inline) enums keep the wire scalar; only NAMED enums get types.
- return { string: 'string', integer: 'int', number: 'float', boolean: 'bool' }[schema.scalar];
- case 'literal':
- return typeof schema.value === 'string'
- ? 'string'
- : typeof schema.value === 'boolean'
- ? 'bool'
- : 'float';
- case 'omit':
- // PHP has no Omit; the base class is the honest annotation.
- return className(schema.base);
- case 'union':
- return phpUnionType(schema.members, model, dateType);
- case 'null':
- case 'object':
- case 'intersection':
- case 'unknown':
- return 'mixed';
- }
-}
-
-/** True when the named schema renders as an `unmarshalX` union dispatcher. */
-function isDiscriminatedUnion(name: string, model: ApiModel): boolean {
- const named = model.schemas.find((candidate) => candidate.name === name);
- return named !== undefined && discriminatorCases(named.schema, model) !== undefined;
-}
-
-/** `date` or `date-time` — the two formats `dateType: Date` turns into objects. */
-function isDateFormat(schema: SchemaModel): boolean {
- const format = schema.metadata?.format;
- return format === 'date' || format === 'date-time';
-}
-
-/**
- * The nullable form of a PHP type. `?T` for a single type, `A|B|null` for a union — PHP
- * forbids mixing `?` with `|`, and `mixed` already includes null.
- */
-function phpNullable(type: string): string {
- if (type === 'mixed' || type.startsWith('?') || type.endsWith('|null')) return type;
- return type.includes('|') ? `${type}|null` : `?${type}`;
-}
-
-/**
- * A union as a native PHP 8.1 union type (`int|string`, `PromotionType|array`). Rich list
- * filters are usually unions, and collapsing them to `mixed` throws away the typing that
- * makes the SDK worth generating. `mixed` cannot be a union member, so a member without a
- * PHP type of its own (inline object, intersection, unknown) forces the whole union to
- * `mixed`. Members that map to the same PHP type collapse to one.
- */
-function phpUnionType(members: SchemaModel[], model: ApiModel, dateType: DateType): string {
- const rendered: string[] = [];
- for (const member of members) {
- // `null` is handled by the caller's nullability check, never as a member here.
- if (member.kind === 'null') continue;
- const type = phpType(member, model, dateType);
- if (type === 'mixed') return 'mixed';
- // A nullable member inside a union contributes its bare type plus null.
- const bare = type.startsWith('?') ? type.slice(1) : type;
- if (!rendered.includes(bare)) rendered.push(bare);
- if (type.startsWith('?') && !rendered.includes('null')) rendered.push('null');
- }
- if (rendered.length === 0) return 'mixed';
- return rendered.join('|');
-}
-
-/** Wire value → typed value expression, or undefined when the raw value is already right. */
-function hydration(
- schema: SchemaModel,
- expr: string,
- model: ApiModel,
- dateType: DateType = 'string'
-): string | undefined {
- const bare = unwrapNullable(schema);
- if (dateType === 'Date' && bare.kind === 'scalar' && bare.scalar === 'string') {
- if (isDateFormat(bare)) return `new \\DateTimeImmutable(${expr})`;
- }
- if (bare.kind === 'omit')
- return hydration({ kind: 'ref', name: bare.base }, expr, model, dateType);
- if (bare.kind === 'ref') {
- const kind = classify(bare.name, model);
- if (kind === 'class') return `${className(bare.name)}::fromArray(${expr})`;
- if (kind === 'enum') return `${className(bare.name)}::from(${expr})`;
- if (isDiscriminatedUnion(bare.name, model)) return `unmarshal${className(bare.name)}(${expr})`;
- const target = deref(bare, model);
- return target === undefined ? undefined : hydration(target, expr, model, dateType);
- }
- if (bare.kind === 'array') {
- const item = hydration(bare.items, '$item', model, dateType);
- if (item === undefined) return undefined;
- return `array_map(static fn ($item) => ${item}, ${expr})`;
- }
- if (bare.kind === 'record') {
- const item = hydration(bare.value, '$item', model, dateType);
- if (item === undefined) return undefined;
- return `array_map(static fn ($item) => ${item}, ${expr})`;
- }
- return undefined;
-}
-
-/** Typed value → wire value expression, or undefined when it serializes as-is. */
-function serialization(
- schema: SchemaModel,
- expr: string,
- model: ApiModel,
- dateType: DateType = 'string'
-): string | undefined {
- const bare = unwrapNullable(schema);
- if (dateType === 'Date' && bare.kind === 'scalar' && bare.scalar === 'string') {
- // A date-only value must not gain a time component on the way out.
- if (bare.metadata?.format === 'date') return `${expr}->format('Y-m-d')`;
- if (bare.metadata?.format === 'date-time') {
- return `${expr}->format(\\DateTimeInterface::ATOM)`;
- }
- }
- if (bare.kind === 'omit') {
- return serialization({ kind: 'ref', name: bare.base }, expr, model, dateType);
- }
- if (bare.kind === 'ref') {
- const kind = classify(bare.name, model);
- if (kind === 'class') return `${expr}->toArray()`;
- if (kind === 'enum') return `${expr}->value`;
- // A union value may be a hydrated member instance or a raw (default-case) array.
- if (isDiscriminatedUnion(bare.name, model)) {
- return `is_object(${expr}) ? ${expr}->toArray() : ${expr}`;
- }
- const target = deref(bare, model);
- return target === undefined ? undefined : serialization(target, expr, model, dateType);
- }
- if (bare.kind === 'array' || bare.kind === 'record') {
- const inner = bare.kind === 'array' ? bare.items : bare.value;
- const item = serialization(inner, '$item', model, dateType);
- if (item === undefined) return undefined;
- return `array_map(static fn ($item) => ${item}, ${expr})`;
- }
- return undefined;
-}
-
-function writeDocComment(
- printer: Printer,
- name: string,
- description?: string,
- tags: string[] = []
-): void {
- const lines = docText(description);
- if (lines.length === 0 && tags.length === 0) return;
- const summary = lines.length === 0 ? name : `${name} — ${lines.join(' ')}`;
- if (tags.length === 0) {
- printer.line(`/** ${summary} */`);
- return;
- }
- printer.line('/**');
- printer.line(` * ${summary}`);
- printer.line(' *');
- for (const tag of tags) printer.line(` * ${tag}`);
- printer.line(' */');
-}
-
-/**
- * The element type behind a PHP type that erases it. `array` and `\Generator` are as
- * specific as PHP's syntax gets, so the docblock carries what they hold — that is what
- * static analysis and readers actually go by.
- */
-function phpElementType(
- schema: SchemaModel | undefined,
- model: ApiModel,
- dateType: DateType
-): string | undefined {
- if (schema === undefined) return undefined;
- const bare = unwrapNullable(schema);
- if (bare.kind === 'ref') {
- const target = deref(bare, model);
- // A named schema that IS an array (a collection alias) keeps its element type.
- return classify(bare.name, model) === 'other'
- ? phpElementType(target, model, dateType)
- : undefined;
- }
- if (bare.kind !== 'array') return undefined;
- const element = phpType(bare.items, model, dateType);
- return element === 'mixed' ? undefined : element;
-}
-
-function writeClass(
- printer: Printer,
- name: string,
- properties: PropertyModel[],
- model: ApiModel,
- dateType: DateType,
- description?: string
-): void {
- // PHP requires defaulted parameters after required ones.
- const ordered = [
- ...properties.filter((property) => property.required),
- ...properties.filter((property) => !property.required),
- ];
- writeDocComment(printer, className(name), description);
- printer.line(`final class ${className(name)}`);
- printer.block(
- '{',
- () => {
- printer.block(
- 'public function __construct(',
- () => {
- for (const property of ordered) {
- const type = phpType(property.schema, model, dateType);
- if (property.required) {
- printer.line(`public ${type} ${'$'}${propertyName(property.name)},`);
- } else {
- const nullable = phpNullable(type);
- printer.line(`public ${nullable} ${'$'}${propertyName(property.name)} = null,`);
- }
- }
- },
- ') {'
- );
- printer.line('}');
- printer.blank();
-
- printer.line('public static function fromArray(array $data): self');
- printer.block(
- '{',
- () => {
- printer.block(
- 'return new self(',
- () => {
- for (const property of ordered) {
- const raw = `$data[${phpString(property.name)}]`;
- const typed = hydration(property.schema, raw, model, dateType);
- const php = propertyName(property.name);
- if (property.required) {
- printer.line(`${php}: ${typed ?? raw},`);
- } else if (typed === undefined) {
- printer.line(`${php}: ${raw} ?? null,`);
- } else {
- printer.line(`${php}: isset(${raw}) ? ${typed} : null,`);
- }
- }
- },
- ');'
- );
- },
- '}'
- );
- printer.blank();
-
- printer.line('public function toArray(): array');
- printer.block(
- '{',
- () => {
- printer.line('$data = [];');
- for (const property of ordered) {
- const value = `$this->${propertyName(property.name)}`;
- const wire = serialization(property.schema, value, model, dateType) ?? value;
- if (property.required) {
- printer.line(`$data[${phpString(property.name)}] = ${wire};`);
- } else {
- printer.block(
- `if (${value} !== null) {`,
- () => {
- printer.line(`$data[${phpString(property.name)}] = ${wire};`);
- },
- '}'
- );
- }
- }
- printer.line('return $data;');
- },
- '}'
- );
- },
- '}'
- );
- printer.blank();
-}
-
-/** Render every named schema: classes (allOf flattened), native enums, union dispatchers. */
-export function renderPhpModels(model: ApiModel, dateType: DateType = 'string'): string {
- const printer = new Printer(' ');
- for (const { name, schema } of model.schemas) {
- const asEnum = enumValues(schema);
- if (asEnum !== undefined && (asEnum.scalar === 'string' || asEnum.scalar === 'integer')) {
- const backing = asEnum.scalar === 'string' ? 'string' : 'int';
- writeDocComment(printer, className(name), schema.description);
- printer.line(`enum ${className(name)}: ${backing}`);
- printer.block(
- '{',
- () => {
- asEnum.values.forEach((value) => {
- const member = identifierFor(String(value), { style: 'pascal', reserved: PHP });
- const literal = typeof value === 'string' ? phpString(value) : String(value);
- printer.line(`case ${member} = ${literal};`);
- });
- },
- '}'
- );
- printer.blank();
- continue;
- }
- if (schema.kind === 'object' || schema.kind === 'intersection') {
- const flat = flattenAllOf(schema, model);
- if (flat !== undefined) {
- writeClass(
- printer,
- name,
- flat.properties,
- model,
- dateType,
- flat.description ?? schema.description
- );
- continue;
- }
- }
- const cases = discriminatorCases(schema, model);
- if (cases !== undefined) {
- const typeName = className(name);
- const table = cases.cases
- .map((entry) => `${entry.value} -> ${className(entry.schemaName)}`)
- .join(', ');
- printer.line(
- `/** ${typeName} is a discriminated union (${phpString(cases.property)}): ${table}. */`
- );
- printer.line(`function unmarshal${typeName}(array $data): mixed`);
- printer.block(
- '{',
- () => {
- printer.block(
- `return match ($data[${phpString(cases.property)}] ?? null) {`,
- () => {
- for (const entry of cases.cases) {
- printer.line(
- `${phpString(entry.value)} => ${className(entry.schemaName)}::fromArray($data),`
- );
- }
- printer.line('default => $data,');
- },
- '};'
- );
- },
- '}'
- );
- printer.blank();
- continue;
- }
- // Everything else (plain unions, aliases, records) has no PHP declaration;
- // references resolve to the underlying type via phpType.
- }
- return printer.toString();
-}
-
-/** The op's primary JSON success schema, or undefined for void/no-body ops. */
-function successSchema(op: OperationModel): SchemaModel | undefined {
- return op.successResponses.find((response) => response.contentType.toLowerCase().includes('json'))
- ?.schema;
-}
-
-function sseResponse(op: OperationModel) {
- return op.successResponses.find((response) =>
- response.contentType.toLowerCase().includes('text/event-stream')
- );
-}
-
-function isMultipart(op: OperationModel): boolean {
- return op.requestBody?.contentType.toLowerCase().includes('multipart') ?? false;
-}
-
-function methodName(op: OperationModel): string {
- return identifierFor(op.name, { style: 'camel', reserved: PHP });
-}
-
-const MUTATING = new Set(['post', 'put', 'patch']);
-
-/** Security literal for the operations table, denormalized from the model's schemes. */
-function phpSecurityLiteral(op: OperationModel, model: ApiModel): string | undefined {
- if (op.security.length === 0) return undefined;
- const alternatives = op.security.map((andSet) => {
- const specs = andSet.flatMap((key): string[] => {
- const scheme = model.securitySchemes.find((candidate) => candidate.key === key);
- if (scheme === undefined) return [];
- if (scheme.kind === 'bearer' || scheme.kind === 'basic') {
- return [`['kind' => ${phpString(scheme.kind)}, 'scheme' => ${phpString(scheme.key)}]`];
- }
- const where =
- scheme.kind === 'apiKeyQuery'
- ? 'query'
- : scheme.kind === 'apiKeyCookie'
- ? 'cookie'
- : 'header';
- const name =
- scheme.kind === 'apiKeyQuery'
- ? scheme.paramName
- : scheme.kind === 'apiKeyCookie'
- ? scheme.cookieName
- : scheme.headerName;
- return [
- `['kind' => 'apiKey', 'scheme' => ${phpString(scheme.key)}, 'name' => ${phpString(name)}, 'in' => ${phpString(where)}]`,
- ];
- });
- return `[${specs.join(', ')}]`;
- });
- return `[${alternatives.join(', ')}]`;
-}
-
-function phpPaginationLiteral(rule: NeutralPaginationRule): string {
- const fields = [
- `'style' => ${phpString(rule.style)}`,
- ...(rule.param !== undefined ? [`'param' => ${phpString(rule.param)}`] : []),
- ...(rule.nextCursor !== undefined ? [`'nextCursor' => ${phpString(rule.nextCursor)}`] : []),
- ...(rule.hasMore !== undefined ? [`'hasMore' => ${phpString(rule.hasMore)}`] : []),
- ...(rule.limitParam !== undefined ? [`'limitParam' => ${phpString(rule.limitParam)}`] : []),
- ...(rule.items !== undefined ? [`'items' => ${phpString(rule.items)}`] : []),
- ];
- return `[${fields.join(', ')}]`;
-}
-
-type MethodArgs = {
- pathArgs: Array<{ php: string; wire: string; type: string }>;
- /** `value` is the expression to send: a date object formats itself, everything else is the variable. */
- queryArgs: Array<{ php: string; wire: string; type: string; value: string }>;
- signature: string[];
-};
-
-/**
- * The argument names a request method declares beside its parameters. A parameter named
- * after one of them takes a suffixed variable instead, so the slot keeps its meaning.
- */
-const SIGNATURE_ARG_SLOTS = ['body', 'headers', 'idempotencyKey'];
-
-function methodArgs(
- op: OperationModel,
- model: ApiModel,
- includeBody: boolean,
- dateType: DateType
-): MethodArgs {
- // Each parameter is its own argument, so path and query names share one namespace with
- // the slots this signature declares itself (`$body`, `$headers`, `$idempotencyKey`).
- // A repeat moves aside (`$id`, `$id_2`): PHP rejects a redefined parameter outright, and
- // a description may legally use one name in two locations.
- const names = uniqueIdentifiers(
- [...op.pathParams, ...op.queryParams].map((param) => param.name),
- { style: 'camel', reserved: PHP, taken: SIGNATURE_ARG_SLOTS }
- );
- const pathArgs = op.pathParams.map((param, index) => ({
- php: names[index],
- wire: param.name,
- type: phpType(param.schema, model, dateType),
- }));
- const queryArgs = op.queryParams.map((param, index) => {
- const php = names[op.pathParams.length + index];
- return {
- php,
- wire: param.name,
- type: phpType(param.schema, model, dateType),
- value: serialization(param.schema, `${'$'}${php}`, model, dateType) ?? `${'$'}${php}`,
- };
- });
- const signature = [
- ...pathArgs.map(({ php, type }) => `${type} ${'$'}${php}`),
- ...(includeBody && op.requestBody
- ? [
- `${isMultipart(op) ? 'array' : phpType(op.requestBody.schema, model, dateType)} ${'$'}body`,
- ]
- : []),
- ...queryArgs.map(({ php, type }) => {
- const nullable = phpNullable(type);
- return `${nullable} ${'$'}${php} = null`;
- }),
- '?array $headers = null',
- ...(includeBody && MUTATING.has(op.method.toLowerCase())
- ? ['?string $idempotencyKey = null']
- : []),
- ];
- return { pathArgs, queryArgs, signature };
-}
-
-/** The shared prologue: resolve auth, build query/url, merge headers. */
-function writeRequestSetup(printer: Printer, op: OperationModel, args: MethodArgs): void {
- printer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`);
- printer.line(
- "[$authHeaders, $query, $cookies] = resolveAuth($op['security'] ?? [], $this->config->auth);"
- );
- for (const { php, wire, value } of args.queryArgs) {
- printer.block(
- `if (${'$'}${php} !== null) {`,
- () => {
- printer.line(`$query[${phpString(wire)}] = ${value};`);
- },
- '}'
- );
- }
- const pathDict = args.pathArgs
- .map(({ php, wire }) => `${phpString(wire)} => ${'$'}${php}`)
- .join(', ');
- printer.line(`$url = buildUrl($this->config->serverUrl, $op['path'], [${pathDict}]);`);
- printer.line('$requestHeaders = array_merge($authHeaders, $headers ?? []);');
- printer.block(
- 'if ($cookies !== []) {',
- () => {
- printer.line("$requestHeaders['Cookie'] = implode('; ', $cookies);");
- },
- '}'
- );
-}
-
-/** Declared response headers as runtime coerce specs: `[wire name, camelCase key, type]`. */
-function envelopeHeaderSpecs(op: OperationModel, model: ApiModel): string {
- const used = new Set();
- const specs = (op.successResponseHeaders ?? []).map((header) => {
- let key = identifierFor(header.name, { style: 'camel', reserved: PHP });
- let suffix = 2;
- while (used.has(key))
- key = `${identifierFor(header.name, { style: 'camel', reserved: PHP })}_${suffix++}`;
- used.add(key);
- const type = headerCoerceType(header.schema, model);
- return `[${phpString(header.name)}, ${phpString(key)}, ${phpString(type)}]`;
- });
- return `[${specs.join(', ')}]`;
-}
-
-function writePhpMethod(
- printer: Printer,
- op: OperationModel,
- model: ApiModel,
- dateType: DateType,
- envelope = false
-): void {
- const args = methodArgs(op, model, true, dateType);
- const sse = sseResponse(op);
- const success = successSchema(op);
- // Non-JSON success bodies (PDFs, images, octet streams) return the raw body string.
- const rawBody =
- sse === undefined &&
- success === undefined &&
- op.successResponses.some((response) => response.contentType !== '');
- const returnType = envelope
- ? 'Envelope'
- : sse !== undefined
- ? '\\Generator'
- : success !== undefined
- ? phpType(success, model, dateType)
- : rawBody
- ? 'string'
- : 'void';
- const name = envelope ? `${methodName(op)}WithHeaders` : methodName(op);
- const element = envelope ? undefined : phpElementType(success, model, dateType);
- writeDocComment(
- printer,
- name,
- envelope
- ? `Like ${methodName(op)}(), returning an Envelope with the declared response headers.`
- : (op.summary ?? `${op.method.toUpperCase()} ${op.path}`),
- element === undefined ? [] : [`@return ${element}[]`]
- );
- printer.line(`public function ${name}(${args.signature.join(', ')}): ${returnType}`);
- printer.block(
- '{',
- () => {
- writeRequestSetup(printer, op, args);
- if (sse !== undefined) {
- const jsonData = sse.schema !== undefined && sse.schema.kind !== 'unknown';
- printer.line('$url = appendQuery($url, $query);');
- printer.block(
- '$open = function (array $extraHeaders) use ($url, $requestHeaders): \\CurlHandle {',
- () => {
- printer.line('$handle = curl_init($url);');
- printer.line('$lines = [];');
- printer.block(
- 'foreach (array_merge($requestHeaders, $extraHeaders) as $name => $value) {',
- () => {
- printer.line("$lines[] = $name . ': ' . $value;");
- },
- '}'
- );
- printer.line('curl_setopt($handle, CURLOPT_HTTPHEADER, $lines);');
- printer.line('return $handle;');
- },
- '};'
- );
- printer.line(`yield from iterSse($open, ${jsonData ? 'true' : 'false'});`);
- return;
- }
- const request = [
- `'operationId' => $op['id']`,
- `'method' => $op['method']`,
- `'url' => $url`,
- `'headers' => $requestHeaders`,
- `'query' => $query`,
- ];
- if (op.requestBody && isMultipart(op)) {
- printer.line('[$contentType, $encoded] = toMultipart($body);');
- request.push(`'body' => $encoded`, `'contentType' => $contentType`);
- } else if (op.requestBody) {
- const wire = serialization(op.requestBody.schema, '$body', model, dateType) ?? '$body';
- printer.line(`$payload = json_encode(${wire});`);
- request.push(
- `'body' => $payload`,
- `'contentType' => ${phpString(op.requestBody.contentType)}`
- );
- }
- if (MUTATING.has(op.method.toLowerCase()) && op.requestBody) {
- request.push(`'idempotencyKey' => $idempotencyKey`);
- }
- printer.line(`$response = send($this->config, [${request.join(', ')}]);`);
- printer.block(
- "if ($response['status'] >= 400) {",
- () => {
- printer.line('throw apiErrorFrom($response);');
- },
- '}'
- );
- const decoded = rawBody
- ? "$response['body']"
- : ((success === undefined
- ? undefined
- : hydration(success, 'decodeJson($response)', model)) ?? 'decodeJson($response)');
- if (envelope) {
- printer.line(`$data = ${decoded};`);
- printer.line(
- `return new Envelope(data: $data, headers: readEnvelopeHeaders($response, ${envelopeHeaderSpecs(op, model)}), status: $response['status']);`
- );
- return;
- }
- if (rawBody) {
- printer.line("return $response['body'];");
- return;
- }
- if (returnType === 'void') {
- printer.line('decodeJson($response);');
- return;
- }
- printer.line(`return ${decoded};`);
- },
- '}'
- );
- printer.blank();
-}
-
-/** `Pages()` / `Items()` generators over the runtime's iterPages. */
-function writePhpPaginationWrappers(
- printer: Printer,
- op: OperationModel,
- model: ApiModel,
- dateType: DateType,
- pageHydration: string | undefined,
- itemHydration: string | undefined,
- itemsPointer: string | undefined,
- itemYield: string
-): void {
- const args = methodArgs(op, model, false, dateType);
- const name = methodName(op);
-
- const writeCall = () => {
- printer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`);
- printer.line('$base = [];');
- for (const { php, wire, value } of args.queryArgs) {
- printer.block(
- `if (${'$'}${php} !== null) {`,
- () => {
- printer.line(`$base[${phpString(wire)}] = ${value};`);
- },
- '}'
- );
- }
- const pathDict = args.pathArgs
- .map(({ php, wire }) => `${phpString(wire)} => ${'$'}${php}`)
- .join(', ');
- printer.block(
- '$call = function (array $params) use ($op, $headers): array {',
- () => {
- printer.line(
- "[$authHeaders, $authQuery, $cookies] = resolveAuth($op['security'] ?? [], $this->config->auth);"
- );
- printer.line(`$url = buildUrl($this->config->serverUrl, $op['path'], [${pathDict}]);`);
- printer.line('$requestHeaders = array_merge($authHeaders, $headers ?? []);');
- printer.block(
- 'if ($cookies !== []) {',
- () => {
- printer.line("$requestHeaders['Cookie'] = implode('; ', $cookies);");
- },
- '}'
- );
- printer.line(
- "$response = send($this->config, ['operationId' => $op['id'], 'method' => $op['method'], 'url' => $url, 'headers' => $requestHeaders, 'query' => array_merge($params, $authQuery)]);"
- );
- printer.block(
- "if ($response['status'] >= 400) {",
- () => {
- printer.line('throw apiErrorFrom($response);');
- },
- '}'
- );
- printer.line('return [decodeJson($response), $response];');
- },
- '};'
- );
- };
-
- const pageType = phpType(successSchema(op) ?? { kind: 'unknown' }, model, dateType);
- const pageYield = pageType === 'mixed' ? 'mixed' : pageType;
- printer.line('/**');
- printer.line(` * ${name} response pages, following the pagination rule automatically.`);
- printer.line(' *');
- printer.line(` * @return \\Generator`);
- printer.line(' */');
- printer.line(`public function ${name}Pages(${args.signature.join(', ')}): \\Generator`);
- printer.block(
- '{',
- () => {
- writeCall();
- printer.block(
- "foreach (iterPages($call, $op['pagination'], $base) as $page) {",
- () => {
- printer.line(`yield ${pageHydration ?? '$page'};`);
- },
- '}'
- );
- },
- '}'
- );
- printer.blank();
-
- printer.line('/**');
- printer.line(` * The items of every ${name} page.`);
- printer.line(' *');
- printer.line(` * @return \\Generator`);
- printer.line(' */');
- printer.line(`public function ${name}Items(${args.signature.join(', ')}): \\Generator`);
- printer.block(
- '{',
- () => {
- writeCall();
- printer.block(
- "foreach (iterPages($call, $op['pagination'], $base) as $page) {",
- () => {
- printer.line(`$items = resolvePointer($page, ${phpString(itemsPointer ?? '')});`);
- printer.block(
- 'foreach (is_array($items) ? $items : [] as $item) {',
- () => {
- printer.line(`yield ${itemHydration ?? '$item'};`);
- },
- '}'
- );
- },
- '}'
- );
- },
- '}'
- );
- printer.blank();
-}
-
-/** The server URL as a PHP expression: literals concatenated with declared-variable arguments. */
-function serverUrlExpression(server: ServerModel): string {
- const declared = new Set(server.variables.map((variable) => variable.name));
- const parts: string[] = [];
- let literal = '';
- let rest = server.url;
- const template = /\{([^{}]+)\}/;
- for (let match = template.exec(rest); match !== null; match = template.exec(rest)) {
- literal += rest.slice(0, match.index);
- if (declared.has(match[1])) {
- if (literal !== '') parts.push(phpString(literal));
- literal = '';
- parts.push(`${'$'}${propertyName(match[1])}`);
- } else {
- // An undeclared variable has nothing to substitute; keep its placeholder visible.
- literal += match[0];
- }
- rest = rest.slice(match.index + match[0].length);
- }
- literal += rest;
- if (literal !== '' || parts.length === 0) parts.push(phpString(literal));
- return parts.join(' . ');
-}
-
-/** One static method per declared server; server variables become named string arguments. */
-function writeServers(printer: Printer, model: ApiModel): void {
- const servers = model.servers ?? [];
- if (servers.length === 0) return;
- const usedNames = new Set();
- printer.line(
- '/** The declared servers; variables default to the values from the description. */'
- );
- printer.line('final class Servers');
- printer.block(
- '{',
- () => {
- servers.forEach((server, index) => {
- let name = identifierFor(server.description ?? `server${index + 1}`, {
- style: 'camel',
- reserved: PHP,
- });
- if (usedNames.has(name)) name = `${name}${index + 1}`;
- usedNames.add(name);
- const params = server.variables.map(
- (variable) =>
- `string ${'$'}${propertyName(variable.name)} = ${phpString(variable.default)}`
- );
- if (index > 0) printer.blank();
- printer.line(`public static function ${name}(${params.join(', ')}): string`);
- printer.block(
- '{',
- () => {
- printer.line(`return ${serverUrlExpression(server)};`);
- },
- '}'
- );
- });
- },
- '}'
- );
- printer.blank();
-}
+ type OperationModel,
+ paginationItemSchema,
+ renderReferencePage,
+ type SampleContext,
+ sseResponse,
+} from '@redocly/client-generator';
+import { PhpPrinter } from '@redocly/client-generator/printers/php';
+import { PHP_RUNTIME_SOURCE } from '@redocly/client-generator/runtime-sources';
+
+import { writeServers } from './client.ts';
+import { phpPaginationLiteral, phpSecurityLiteral } from './descriptor.ts';
+import { hydration, renderPhpModels } from './models.ts';
+import { methodIdents, methodName, PHP, phpString, propertyName } from './naming.ts';
+import { writePhpMethod } from './operations.ts';
+import { writePhpPaginationWrappers } from './pagination.ts';
+import { phpType } from './types.ts';
+
+export { renderPhpModels } from './models.ts';
+export { phpType } from './types.ts';
/** Drop the standalone header ( {
- const printer = new Printer(' ');
+export const phpGenerator: Generator = ({ model, output, banner, emit, pagination }) => {
+ const printer = new PhpPrinter();
const dateType = emit.dateType ?? 'string';
const namespace = identifierFor(model.title, { style: 'pascal', reserved: PHP });
printer.line(' {
printer.blank();
printer.line(renderPhpModels(model, dateType));
writeServers(printer, model);
- printer.line('// ─── Embedded runtime (@redocly/client-generator php runtime) ───');
- printer.line(stripPhpHeader(PHP_RUNTIME_SOURCE));
- printer.blank();
+ if (emit.runtime === 'module') {
+ // The runtime file re-declares this same namespace, so the require binds the
+ // exact names the inline stitching would have defined at this position.
+ printer.line('// ─── Runtime (a real file beside this one, written by the same run) ───');
+ printer.line("require_once __DIR__ . '/runtime.php';");
+ printer.blank();
+ } else {
+ printer.line('// ─── Embedded runtime (@redocly/client-generator php runtime) ───');
+ printer.line(stripPhpHeader(PHP_RUNTIME_SOURCE));
+ printer.blank();
+ }
const operations = model.services.flatMap((service) => service.operations);
+ const idents = methodIdents(model);
+ // Pagination arrives RESOLVED from the pipeline — one fit-verified answer per run.
const paginationRules = new Map();
for (const op of operations) {
- const rule = paginationRuleFor(op, emit.pagination as Record | undefined);
- if (rule !== undefined) paginationRules.set(op.name, rule);
+ const spec = pagination?.get(op.name)?.spec;
+ if (spec !== undefined) paginationRules.set(op.name, spec);
}
printer.block(
@@ -987,7 +107,7 @@ export const phpGenerator: Generator = ({ model, outputPath, emit }) => {
);
printer.blank();
- writeDocComment(printer, 'Client', `Client for ${model.title} (${model.version}).`);
+ printer.doc('Client', `Client for ${model.title} (${model.version}).`);
// Not final: PHP test suites mock concrete classes (createMock(Client::class)).
printer.line('class Client');
printer.block(
@@ -1012,27 +132,23 @@ export const phpGenerator: Generator = ({ model, outputPath, emit }) => {
printer.blank();
for (const op of operations) {
- writePhpMethod(printer, op, model, dateType);
+ const ident = idents.get(op.name)!;
+ writePhpMethod(printer, op, ident, model, dateType);
if (sseResponse(op) === undefined && (op.successResponseHeaders?.length ?? 0) > 0) {
- writePhpMethod(printer, op, model, dateType, true);
+ writePhpMethod(printer, op, ident, model, dateType, true);
}
const rule = paginationRules.get(op.name);
if (rule === undefined) continue;
- const success = successSchema(op);
+ const success = jsonSuccessSchema(op);
const pageHydration =
success === undefined ? undefined : hydration(success, '$page', model, dateType);
- // Resolve the items ARRAY, then take its raw element, so a `ref` element
- // keeps its class name (a deref'd result would hydrate as plain data).
- const itemsArray =
- success !== undefined && rule.items !== undefined
- ? schemaAtPointer(success, rule.items, model)
- : undefined;
- const element = itemsArray?.kind === 'array' ? itemsArray.items : undefined;
+ const element = paginationItemSchema(success, rule.items, model);
const itemHydration =
element === undefined ? undefined : hydration(element, '$item', model, dateType);
writePhpPaginationWrappers(
printer,
op,
+ ident,
model,
dateType,
pageHydration,
@@ -1045,7 +161,18 @@ export const phpGenerator: Generator = ({ model, outputPath, emit }) => {
'}'
);
- return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.php'), content: printer.toString() }];
+ const entry = { path: output.path.replace(/\.[^.\\/]+$/, '.php'), content: printer.toString() };
+ if (emit.runtime !== 'module') return [entry];
+ // The runtime, verbatim except its namespace: rewritten to the client's, so one
+ // namespace spans both files and every bare reference resolves unchanged.
+ const header = banner.map((line) => `// ${line}`).join('\n');
+ const runtimeSource = PHP_RUNTIME_SOURCE.replace(/^namespace .*$/m, `namespace ${namespace};`)
+ .replace(/^<\?php\n/, `${methodName(op)}(${args.join(', ')});\n`,
+ source: `require '${file}';\n\nuse ${namespace}\\{Client, Config};\n\n$client = new Client(new Config());\n$result = $client->${methodIdents(ctx.model).get(op.name) ?? methodName(op)}(${args.join(', ')});\n`,
};
}
@@ -1072,9 +199,9 @@ export function phpSample(op: OperationModel, ctx: SampleContext): CodeSample {
* from `phpSample` — this generator's own hook — so the page can only ever show the syntax
* of the SDK beside it, and ejecting this generator takes the page with it.
*/
-export const phpDocs: Generator = ({ model, outputPath, emit }) => [
+export const phpDocs: Generator = ({ model, output, emit, pagination }) => [
{
- path: outputPath.replace(/\.[^.\\/]+$/, '.php.md'),
+ path: output.path.replace(/\.[^.\\/]+$/, '.php.md'),
content: renderReferencePage(model, {
title: `${model.title} PHP SDK reference`,
frontmatter: emit.docsFrontmatter === true,
@@ -1084,8 +211,8 @@ export const phpDocs: Generator = ({ model, outputPath, emit }) => [
fence: 'php',
requires: 'The SDK needs the curl extension.',
},
- sample: (op) => phpSample(op, { model, emit, outputPath }),
- pagination: emit.pagination,
+ sample: (op) => phpSample(op, { model, emit, outputPath: output.path }),
+ paginated: new Set(pagination?.keys() ?? []),
}),
},
];
diff --git a/packages/client-generator/src/generators/php/models.ts b/packages/client-generator/src/generators/php/models.ts
new file mode 100644
index 0000000000..a31b7c38ad
--- /dev/null
+++ b/packages/client-generator/src/generators/php/models.ts
@@ -0,0 +1,271 @@
+// The `models` stage: named schemas as promoted-constructor classes with
+// fromArray/toArray hydration, native backed enums, and match-based union
+// dispatchers — plus the wire↔typed value expressions the methods reuse.
+
+import {
+ type ApiModel,
+ type DateType,
+ deref,
+ discriminatorCases,
+ enumValues,
+ flattenAllOf,
+ type PropertyModel,
+ type SchemaModel,
+ uniqueIdentifiers,
+ unwrapNullable,
+} from '@redocly/client-generator';
+import { PhpPrinter } from '@redocly/client-generator/printers/php';
+
+import { className, PHP, phpString, propertyName } from './naming.ts';
+import { classify, isDateFormat, phpNullable, phpType } from './types.ts';
+
+/** True when the named schema renders as an `unmarshalX` union dispatcher. */
+function isDiscriminatedUnion(name: string, model: ApiModel): boolean {
+ const named = model.schemas.find((candidate) => candidate.name === name);
+ return named !== undefined && discriminatorCases(named.schema, model) !== undefined;
+}
+
+/** Wire value → typed value expression, or undefined when the raw value is already right. */
+export function hydration(
+ schema: SchemaModel,
+ expr: string,
+ model: ApiModel,
+ // Required on purpose: a defaulted `'string'` let a call site forget it, and the method
+ // then returned a raw string where its own signature declared `\DateTimeImmutable`.
+ dateType: DateType
+): string | undefined {
+ const bare = unwrapNullable(schema);
+ if (dateType === 'Date' && bare.kind === 'scalar' && bare.scalar === 'string') {
+ if (isDateFormat(bare)) return `new \\DateTimeImmutable(${expr})`;
+ }
+ if (bare.kind === 'omit')
+ return hydration({ kind: 'ref', name: bare.base }, expr, model, dateType);
+ if (bare.kind === 'ref') {
+ const kind = classify(bare.name, model);
+ if (kind === 'class') return `${className(bare.name)}::fromArray(${expr})`;
+ if (kind === 'enum') return `${className(bare.name)}::from(${expr})`;
+ if (isDiscriminatedUnion(bare.name, model)) return `unmarshal${className(bare.name)}(${expr})`;
+ const target = deref(bare, model);
+ return target === undefined ? undefined : hydration(target, expr, model, dateType);
+ }
+ if (bare.kind === 'array') {
+ const item = hydration(bare.items, '$item', model, dateType);
+ if (item === undefined) return undefined;
+ return `array_map(static fn ($item) => ${item}, ${expr})`;
+ }
+ if (bare.kind === 'record') {
+ const item = hydration(bare.value, '$item', model, dateType);
+ if (item === undefined) return undefined;
+ return `array_map(static fn ($item) => ${item}, ${expr})`;
+ }
+ return undefined;
+}
+
+/** Typed value → wire value expression, or undefined when it serializes as-is. */
+export function serialization(
+ schema: SchemaModel,
+ expr: string,
+ model: ApiModel,
+ dateType: DateType = 'string'
+): string | undefined {
+ const bare = unwrapNullable(schema);
+ if (dateType === 'Date' && bare.kind === 'scalar' && bare.scalar === 'string') {
+ // A date-only value must not gain a time component on the way out.
+ if (bare.metadata?.format === 'date') return `${expr}->format('Y-m-d')`;
+ if (bare.metadata?.format === 'date-time') {
+ return `${expr}->format(\\DateTimeInterface::ATOM)`;
+ }
+ }
+ if (bare.kind === 'omit') {
+ return serialization({ kind: 'ref', name: bare.base }, expr, model, dateType);
+ }
+ if (bare.kind === 'ref') {
+ const kind = classify(bare.name, model);
+ if (kind === 'class') return `${expr}->toArray()`;
+ if (kind === 'enum') return `${expr}->value`;
+ // A union value may be a hydrated member instance or a raw (default-case) array.
+ if (isDiscriminatedUnion(bare.name, model)) {
+ return `is_object(${expr}) ? ${expr}->toArray() : ${expr}`;
+ }
+ const target = deref(bare, model);
+ return target === undefined ? undefined : serialization(target, expr, model, dateType);
+ }
+ if (bare.kind === 'array' || bare.kind === 'record') {
+ const inner = bare.kind === 'array' ? bare.items : bare.value;
+ const item = serialization(inner, '$item', model, dateType);
+ if (item === undefined) return undefined;
+ return `array_map(static fn ($item) => ${item}, ${expr})`;
+ }
+ return undefined;
+}
+
+function writeClass(
+ printer: PhpPrinter,
+ name: string,
+ properties: PropertyModel[],
+ model: ApiModel,
+ dateType: DateType,
+ description?: string
+): void {
+ // PHP requires defaulted parameters after required ones.
+ const ordered = [
+ ...properties.filter((property) => property.required),
+ ...properties.filter((property) => !property.required),
+ ];
+ printer.doc(className(name), description);
+ printer.line(`final class ${className(name)}`);
+ printer.block(
+ '{',
+ () => {
+ printer.block(
+ 'public function __construct(',
+ () => {
+ for (const property of ordered) {
+ const type = phpType(property.schema, model, dateType);
+ if (property.required) {
+ printer.line(`public ${type} ${'$'}${propertyName(property.name)},`);
+ } else {
+ const nullable = phpNullable(type);
+ printer.line(`public ${nullable} ${'$'}${propertyName(property.name)} = null,`);
+ }
+ }
+ },
+ ') {'
+ );
+ printer.line('}');
+ printer.blank();
+
+ printer.line('public static function fromArray(array $data): self');
+ printer.block(
+ '{',
+ () => {
+ printer.block(
+ 'return new self(',
+ () => {
+ for (const property of ordered) {
+ const raw = `$data[${phpString(property.name)}]`;
+ const typed = hydration(property.schema, raw, model, dateType);
+ const php = propertyName(property.name);
+ if (property.required) {
+ printer.line(`${php}: ${typed ?? raw},`);
+ } else if (typed === undefined) {
+ printer.line(`${php}: ${raw} ?? null,`);
+ } else {
+ printer.line(`${php}: isset(${raw}) ? ${typed} : null,`);
+ }
+ }
+ },
+ ');'
+ );
+ },
+ '}'
+ );
+ printer.blank();
+
+ printer.line('public function toArray(): array');
+ printer.block(
+ '{',
+ () => {
+ printer.line('$data = [];');
+ for (const property of ordered) {
+ const value = `$this->${propertyName(property.name)}`;
+ const wire = serialization(property.schema, value, model, dateType) ?? value;
+ if (property.required) {
+ printer.line(`$data[${phpString(property.name)}] = ${wire};`);
+ } else {
+ printer.block(
+ `if (${value} !== null) {`,
+ () => {
+ printer.line(`$data[${phpString(property.name)}] = ${wire};`);
+ },
+ '}'
+ );
+ }
+ }
+ printer.line('return $data;');
+ },
+ '}'
+ );
+ },
+ '}'
+ );
+ printer.blank();
+}
+
+/** Render every named schema: classes (allOf flattened), native enums, union dispatchers. */
+export function renderPhpModels(model: ApiModel, dateType: DateType = 'string'): string {
+ const printer = new PhpPrinter();
+ for (const { name, schema } of model.schemas) {
+ const asEnum = enumValues(schema);
+ if (asEnum !== undefined && (asEnum.scalar === 'string' || asEnum.scalar === 'integer')) {
+ const backing = asEnum.scalar === 'string' ? 'string' : 'int';
+ printer.doc(className(name), schema.description);
+ printer.line(`enum ${className(name)}: ${backing}`);
+ printer.block(
+ '{',
+ () => {
+ // `1.5` and `15` fold to one pascal name; PHP rejects a duplicate case.
+ const members = uniqueIdentifiers(
+ asEnum.values.map((value) => String(value)),
+ { style: 'pascal', reserved: PHP }
+ );
+ asEnum.values.forEach((value, index) => {
+ const literal = typeof value === 'string' ? phpString(value) : String(value);
+ printer.line(`case ${members[index]} = ${literal};`);
+ });
+ },
+ '}'
+ );
+ printer.blank();
+ continue;
+ }
+ if (schema.kind === 'object' || schema.kind === 'intersection') {
+ const flat = flattenAllOf(schema, model);
+ if (flat !== undefined) {
+ writeClass(
+ printer,
+ name,
+ flat.properties,
+ model,
+ dateType,
+ flat.description ?? schema.description
+ );
+ continue;
+ }
+ }
+ const cases = discriminatorCases(schema, model);
+ if (cases !== undefined) {
+ const typeName = className(name);
+ const table = cases.cases
+ .map((entry) => `${entry.value} -> ${className(entry.schemaName)}`)
+ .join(', ');
+ printer.line(
+ `/** ${typeName} is a discriminated union (${phpString(cases.property)}): ${table}. */`
+ );
+ printer.line(`function unmarshal${typeName}(array $data): mixed`);
+ printer.block(
+ '{',
+ () => {
+ printer.block(
+ `return match ($data[${phpString(cases.property)}] ?? null) {`,
+ () => {
+ for (const entry of cases.cases) {
+ printer.line(
+ `${phpString(entry.value)} => ${className(entry.schemaName)}::fromArray($data),`
+ );
+ }
+ printer.line('default => $data,');
+ },
+ '};'
+ );
+ },
+ '}'
+ );
+ printer.blank();
+ continue;
+ }
+ // Everything else (plain unions, aliases, records) has no PHP declaration;
+ // references resolve to the underlying type via phpType.
+ }
+ return printer.toString();
+}
diff --git a/packages/client-generator/src/generators/php/naming.ts b/packages/client-generator/src/generators/php/naming.ts
new file mode 100644
index 0000000000..bd53b5c1c7
--- /dev/null
+++ b/packages/client-generator/src/generators/php/naming.ts
@@ -0,0 +1,47 @@
+// The `naming` stage: the shared printer/naming instance, the string escaper, and
+// the collision-free class/property/method identifiers every other stage builds on.
+
+import {
+ type ApiModel,
+ identifierFor,
+ type OperationModel,
+ RESERVED_WORDS,
+ uniqueIdentifiers,
+} from '@redocly/client-generator';
+import { PhpPrinter } from '@redocly/client-generator/printers/php';
+
+export const PHP = RESERVED_WORDS.php;
+
+// Naming and escaping delegate to the printer — one implementation, one policy.
+export const naming = new PhpPrinter();
+
+export function className(name: string): string {
+ return naming.typeName(name);
+}
+
+export function propertyName(name: string): string {
+ return naming.memberName(name);
+}
+
+/** `'…'` with backslashes and quotes escaped — safe for any spec-supplied text. */
+export function phpString(value: string): string {
+ return naming.string(value);
+}
+
+export function methodName(op: OperationModel): string {
+ return identifierFor(op.name, { style: 'camel', reserved: PHP });
+}
+
+/**
+ * The method name for every operation, unique across the client — PHP fatals on a
+ * redeclared method, and two operationIds may camel-case to one name (`get-user`,
+ * `getUser`). Keyed by the IR name, which the sanitizer already made unique.
+ */
+export function methodIdents(model: ApiModel): Map {
+ const operations = model.services.flatMap((service) => service.operations);
+ const names = uniqueIdentifiers(
+ operations.map((op) => op.name),
+ { style: 'camel', reserved: PHP }
+ );
+ return new Map(operations.map((op, index) => [op.name, names[index]]));
+}
diff --git a/packages/client-generator/src/generators/php/operations.ts b/packages/client-generator/src/generators/php/operations.ts
new file mode 100644
index 0000000000..d3e1ad71a8
--- /dev/null
+++ b/packages/client-generator/src/generators/php/operations.ts
@@ -0,0 +1,228 @@
+// The `operations` stage: one typed request method per operation, plus the
+// argument planning and request prologue it shares with the pagination wrappers.
+
+import {
+ type ApiModel,
+ type DateType,
+ isMultipartBody,
+ jsonSuccessSchema,
+ type OperationModel,
+ sseResponse,
+ uniqueIdentifiers,
+} from '@redocly/client-generator';
+import type { PhpPrinter } from '@redocly/client-generator/printers/php';
+
+import { envelopeHeaderSpecs } from './descriptor.ts';
+import { hydration, serialization } from './models.ts';
+import { PHP, phpString } from './naming.ts';
+import { phpElementType, phpNullable, phpType } from './types.ts';
+
+const MUTATING = new Set(['post', 'put', 'patch']);
+
+type MethodArgs = {
+ pathArgs: Array<{ php: string; wire: string; type: string }>;
+ /** `value` is the expression to send: a date object formats itself, everything else is the variable. */
+ queryArgs: Array<{ php: string; wire: string; type: string; value: string }>;
+ signature: string[];
+};
+
+/**
+ * The argument names a request method declares beside its parameters. A parameter named
+ * after one of them takes a suffixed variable instead, so the slot keeps its meaning.
+ */
+const SIGNATURE_ARG_SLOTS = ['body', 'headers', 'idempotencyKey'];
+
+export function methodArgs(
+ op: OperationModel,
+ model: ApiModel,
+ includeBody: boolean,
+ dateType: DateType
+): MethodArgs {
+ // Each parameter is its own argument, so path and query names share one namespace with
+ // the slots this signature declares itself (`$body`, `$headers`, `$idempotencyKey`).
+ // A repeat moves aside (`$id`, `$id_2`): PHP rejects a redefined parameter outright, and
+ // a description may legally use one name in two locations.
+ const names = uniqueIdentifiers(
+ [...op.pathParams, ...op.queryParams].map((param) => param.name),
+ { style: 'camel', reserved: PHP, taken: SIGNATURE_ARG_SLOTS }
+ );
+ const pathArgs = op.pathParams.map((param, index) => ({
+ php: names[index],
+ wire: param.name,
+ type: phpType(param.schema, model, dateType),
+ }));
+ const queryArgs = op.queryParams.map((param, index) => {
+ const php = names[op.pathParams.length + index];
+ return {
+ php,
+ wire: param.name,
+ type: phpType(param.schema, model, dateType),
+ value: serialization(param.schema, `${'$'}${php}`, model, dateType) ?? `${'$'}${php}`,
+ };
+ });
+ const signature = [
+ ...pathArgs.map(({ php, type }) => `${type} ${'$'}${php}`),
+ ...(includeBody && op.requestBody
+ ? [
+ `${isMultipartBody(op) ? 'array' : phpType(op.requestBody.schema, model, dateType)} ${'$'}body`,
+ ]
+ : []),
+ ...queryArgs.map(({ php, type }) => {
+ const nullable = phpNullable(type);
+ return `${nullable} ${'$'}${php} = null`;
+ }),
+ '?array $headers = null',
+ ...(includeBody && MUTATING.has(op.method.toLowerCase())
+ ? ['?string $idempotencyKey = null']
+ : []),
+ ];
+ return { pathArgs, queryArgs, signature };
+}
+
+/** The shared prologue: resolve auth, build query/url, merge headers. */
+function writeRequestSetup(printer: PhpPrinter, op: OperationModel, args: MethodArgs): void {
+ printer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`);
+ printer.line(
+ "[$authHeaders, $query, $cookies] = resolveAuth($op['security'] ?? [], $this->config->auth);"
+ );
+ for (const { php, wire, value } of args.queryArgs) {
+ printer.block(
+ `if (${'$'}${php} !== null) {`,
+ () => {
+ printer.line(`$query[${phpString(wire)}] = ${value};`);
+ },
+ '}'
+ );
+ }
+ const pathDict = args.pathArgs
+ .map(({ php, wire }) => `${phpString(wire)} => ${'$'}${php}`)
+ .join(', ');
+ printer.line(`$url = buildUrl($this->config->serverUrl, $op['path'], [${pathDict}]);`);
+ printer.line('$requestHeaders = array_merge($authHeaders, $headers ?? []);');
+ printer.block(
+ 'if ($cookies !== []) {',
+ () => {
+ printer.line("$requestHeaders['Cookie'] = implode('; ', $cookies);");
+ },
+ '}'
+ );
+}
+
+export function writePhpMethod(
+ printer: PhpPrinter,
+ op: OperationModel,
+ ident: string,
+ model: ApiModel,
+ dateType: DateType,
+ envelope = false
+): void {
+ const args = methodArgs(op, model, true, dateType);
+ const sse = sseResponse(op);
+ const success = jsonSuccessSchema(op);
+ // Non-JSON success bodies (PDFs, images, octet streams) return the raw body string.
+ const rawBody =
+ sse === undefined &&
+ success === undefined &&
+ op.successResponses.some((response) => response.contentType !== '');
+ const returnType = envelope
+ ? 'Envelope'
+ : sse !== undefined
+ ? '\\Generator'
+ : success !== undefined
+ ? phpType(success, model, dateType)
+ : rawBody
+ ? 'string'
+ : 'void';
+ const name = envelope ? `${ident}WithHeaders` : ident;
+ const element = envelope ? undefined : phpElementType(success, model, dateType);
+ printer.doc(
+ name,
+ envelope
+ ? `Like ${ident}(), returning an Envelope with the declared response headers.`
+ : (op.summary ?? `${op.method.toUpperCase()} ${op.path}`),
+ element === undefined ? [] : [`@return ${element}[]`]
+ );
+ printer.line(`public function ${name}(${args.signature.join(', ')}): ${returnType}`);
+ printer.block(
+ '{',
+ () => {
+ writeRequestSetup(printer, op, args);
+ if (sse !== undefined) {
+ const jsonData = sse.schema !== undefined && sse.schema.kind !== 'unknown';
+ printer.line('$url = appendQuery($url, $query);');
+ printer.block(
+ '$open = function (array $extraHeaders) use ($url, $requestHeaders): \\CurlHandle {',
+ () => {
+ printer.line('$handle = curl_init($url);');
+ printer.line('$lines = [];');
+ printer.block(
+ 'foreach (array_merge($requestHeaders, $extraHeaders) as $name => $value) {',
+ () => {
+ printer.line("$lines[] = $name . ': ' . $value;");
+ },
+ '}'
+ );
+ printer.line('curl_setopt($handle, CURLOPT_HTTPHEADER, $lines);');
+ printer.line('return $handle;');
+ },
+ '};'
+ );
+ printer.line(`yield from iterSse($open, ${jsonData ? 'true' : 'false'});`);
+ return;
+ }
+ const request = [
+ `'operationId' => $op['id']`,
+ `'method' => $op['method']`,
+ `'url' => $url`,
+ `'headers' => $requestHeaders`,
+ `'query' => $query`,
+ ];
+ if (op.requestBody && isMultipartBody(op)) {
+ printer.line('[$contentType, $encoded] = toMultipart($body);');
+ request.push(`'body' => $encoded`, `'contentType' => $contentType`);
+ } else if (op.requestBody) {
+ const wire = serialization(op.requestBody.schema, '$body', model, dateType) ?? '$body';
+ printer.line(`$payload = json_encode(${wire});`);
+ request.push(
+ `'body' => $payload`,
+ `'contentType' => ${phpString(op.requestBody.contentType)}`
+ );
+ }
+ if (MUTATING.has(op.method.toLowerCase()) && op.requestBody) {
+ request.push(`'idempotencyKey' => $idempotencyKey`);
+ }
+ printer.line(`$response = send($this->config, [${request.join(', ')}]);`);
+ printer.block(
+ "if ($response['status'] >= 400) {",
+ () => {
+ printer.line('throw apiErrorFrom($response);');
+ },
+ '}'
+ );
+ const decoded = rawBody
+ ? "$response['body']"
+ : ((success === undefined
+ ? undefined
+ : hydration(success, 'decodeJson($response)', model, dateType)) ??
+ 'decodeJson($response)');
+ if (envelope) {
+ printer.line(`$data = ${decoded};`);
+ printer.line(
+ `return new Envelope(data: $data, headers: readEnvelopeHeaders($response, ${envelopeHeaderSpecs(op, model)}), status: $response['status']);`
+ );
+ return;
+ }
+ if (rawBody) {
+ printer.line("return $response['body'];");
+ return;
+ }
+ if (returnType === 'void') {
+ printer.line('decodeJson($response);');
+ return;
+ }
+ printer.line(`return ${decoded};`);
+ },
+ '}'
+ );
+ printer.blank();
+}
diff --git a/packages/client-generator/src/generators/php/pagination.ts b/packages/client-generator/src/generators/php/pagination.ts
new file mode 100644
index 0000000000..ea7532c004
--- /dev/null
+++ b/packages/client-generator/src/generators/php/pagination.ts
@@ -0,0 +1,129 @@
+// The `pagination` stage: the `Pages()` / `Items()` generator methods
+// over the runtime's iterPages.
+
+import {
+ type ApiModel,
+ type DateType,
+ jsonSuccessSchema,
+ type OperationModel,
+} from '@redocly/client-generator';
+import type { PhpPrinter } from '@redocly/client-generator/printers/php';
+
+import { phpString } from './naming.ts';
+import { methodArgs } from './operations.ts';
+import { phpType } from './types.ts';
+
+/** `Pages()` / `Items()` generators over the runtime's iterPages. */
+export function writePhpPaginationWrappers(
+ printer: PhpPrinter,
+ op: OperationModel,
+ ident: string,
+ model: ApiModel,
+ dateType: DateType,
+ pageHydration: string | undefined,
+ itemHydration: string | undefined,
+ itemsPointer: string | undefined,
+ itemYield: string
+): void {
+ const args = methodArgs(op, model, false, dateType);
+ const name = ident;
+
+ const writeCall = () => {
+ printer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`);
+ printer.line('$base = [];');
+ for (const { php, wire, value } of args.queryArgs) {
+ printer.block(
+ `if (${'$'}${php} !== null) {`,
+ () => {
+ printer.line(`$base[${phpString(wire)}] = ${value};`);
+ },
+ '}'
+ );
+ }
+ const pathDict = args.pathArgs
+ .map(({ php, wire }) => `${phpString(wire)} => ${'$'}${php}`)
+ .join(', ');
+ printer.block(
+ '$call = function (array $params) use ($op, $headers): array {',
+ () => {
+ printer.line(
+ "[$authHeaders, $authQuery, $cookies] = resolveAuth($op['security'] ?? [], $this->config->auth);"
+ );
+ printer.line(`$url = buildUrl($this->config->serverUrl, $op['path'], [${pathDict}]);`);
+ printer.line('$requestHeaders = array_merge($authHeaders, $headers ?? []);');
+ printer.block(
+ 'if ($cookies !== []) {',
+ () => {
+ printer.line("$requestHeaders['Cookie'] = implode('; ', $cookies);");
+ },
+ '}'
+ );
+ printer.line(
+ "$response = send($this->config, ['operationId' => $op['id'], 'method' => $op['method'], 'url' => $url, 'headers' => $requestHeaders, 'query' => array_merge($params, $authQuery)]);"
+ );
+ printer.block(
+ "if ($response['status'] >= 400) {",
+ () => {
+ printer.line('throw apiErrorFrom($response);');
+ },
+ '}'
+ );
+ printer.line('return [decodeJson($response), $response];');
+ },
+ '};'
+ );
+ };
+
+ const pageType = phpType(jsonSuccessSchema(op) ?? { kind: 'unknown' }, model, dateType);
+ const pageYield = pageType === 'mixed' ? 'mixed' : pageType;
+ printer.line('/**');
+ printer.line(` * ${name} response pages, following the pagination rule automatically.`);
+ printer.line(' *');
+ printer.line(` * @return \\Generator`);
+ printer.line(' */');
+ printer.line(`public function ${name}Pages(${args.signature.join(', ')}): \\Generator`);
+ printer.block(
+ '{',
+ () => {
+ writeCall();
+ printer.block(
+ "foreach (iterPages($call, $op['pagination'], $base) as $page) {",
+ () => {
+ printer.line(`yield ${pageHydration ?? '$page'};`);
+ },
+ '}'
+ );
+ },
+ '}'
+ );
+ printer.blank();
+
+ printer.line('/**');
+ printer.line(` * The items of every ${name} page.`);
+ printer.line(' *');
+ printer.line(` * @return \\Generator`);
+ printer.line(' */');
+ printer.line(`public function ${name}Items(${args.signature.join(', ')}): \\Generator`);
+ printer.block(
+ '{',
+ () => {
+ writeCall();
+ printer.block(
+ "foreach (iterPages($call, $op['pagination'], $base) as $page) {",
+ () => {
+ printer.line(`$items = resolvePointer($page, ${phpString(itemsPointer ?? '')});`);
+ printer.block(
+ 'foreach (is_array($items) ? $items : [] as $item) {',
+ () => {
+ printer.line(`yield ${itemHydration ?? '$item'};`);
+ },
+ '}'
+ );
+ },
+ '}'
+ );
+ },
+ '}'
+ );
+ printer.blank();
+}
diff --git a/packages/client-generator/runtime/php/runtime.php b/packages/client-generator/src/generators/php/runtime/runtime.php
similarity index 100%
rename from packages/client-generator/runtime/php/runtime.php
rename to packages/client-generator/src/generators/php/runtime/runtime.php
diff --git a/packages/client-generator/src/generators/php/types.ts b/packages/client-generator/src/generators/php/types.ts
new file mode 100644
index 0000000000..ab3742c1d0
--- /dev/null
+++ b/packages/client-generator/src/generators/php/types.ts
@@ -0,0 +1,144 @@
+// The `types` stage: the PHP type declaration for a schema, its nullable and
+// union forms, and the element type PHP's own syntax erases.
+
+import {
+ type ApiModel,
+ type DateType,
+ deref,
+ enumValues,
+ flattenAllOf,
+ isNullable,
+ type SchemaModel,
+ unwrapNullable,
+} from '@redocly/client-generator';
+
+import { className } from './naming.ts';
+
+/** What a named schema renders as: a class, a native enum, or nothing (alias). */
+export function classify(name: string, model: ApiModel): 'class' | 'enum' | 'other' {
+ const named = model.schemas.find((candidate) => candidate.name === name);
+ if (named === undefined) return 'other';
+ const schema = named.schema;
+ const asEnum = enumValues(schema);
+ if (asEnum !== undefined && (asEnum.scalar === 'string' || asEnum.scalar === 'integer')) {
+ return 'enum';
+ }
+ if (
+ (schema.kind === 'object' || schema.kind === 'intersection') &&
+ flattenAllOf(schema, model) !== undefined
+ ) {
+ return 'class';
+ }
+ return 'other';
+}
+
+/** The PHP type declaration for a schema (arrays and unions widen to array/mixed). */
+export function phpType(
+ schema: SchemaModel,
+ model: ApiModel,
+ dateType: DateType = 'string'
+): string {
+ if (isNullable(schema)) {
+ const inner = phpType(unwrapNullable(schema), model, dateType);
+ return phpNullable(inner);
+ }
+ switch (schema.kind) {
+ case 'scalar':
+ // Under `dateType: Date`, date and date-time become DateTimeImmutable — PHP's
+ // immutable date object parses and formats both wire shapes.
+ if (dateType === 'Date' && schema.scalar === 'string' && isDateFormat(schema)) {
+ return '\\DateTimeImmutable';
+ }
+ return { string: 'string', integer: 'int', number: 'float', boolean: 'bool' }[schema.scalar];
+ case 'array':
+ case 'record':
+ return 'array';
+ case 'ref': {
+ const kind = classify(schema.name, model);
+ if (kind === 'class' || kind === 'enum') return className(schema.name);
+ const target = deref(schema, model);
+ return target === undefined ? 'mixed' : phpType(target, model, dateType);
+ }
+ case 'enum':
+ // Anonymous (inline) enums keep the wire scalar; only NAMED enums get types.
+ return { string: 'string', integer: 'int', number: 'float', boolean: 'bool' }[schema.scalar];
+ case 'literal':
+ return typeof schema.value === 'string'
+ ? 'string'
+ : typeof schema.value === 'boolean'
+ ? 'bool'
+ : 'float';
+ case 'omit':
+ // PHP has no Omit; the base class is the honest annotation.
+ return className(schema.base);
+ case 'union':
+ return phpUnionType(schema.members, model, dateType);
+ case 'null':
+ case 'object':
+ case 'intersection':
+ case 'unknown':
+ return 'mixed';
+ }
+}
+
+/** `date` or `date-time` — the two formats `dateType: Date` turns into objects. */
+export function isDateFormat(schema: SchemaModel): boolean {
+ const format = schema.metadata?.format;
+ return format === 'date' || format === 'date-time';
+}
+
+/**
+ * The nullable form of a PHP type. `?T` for a single type, `A|B|null` for a union — PHP
+ * forbids mixing `?` with `|`, and `mixed` already includes null.
+ */
+export function phpNullable(type: string): string {
+ if (type === 'mixed' || type.startsWith('?') || type.endsWith('|null')) return type;
+ return type.includes('|') ? `${type}|null` : `?${type}`;
+}
+
+/**
+ * A union as a native PHP 8.1 union type (`int|string`, `PromotionType|array`). Rich list
+ * filters are usually unions, and collapsing them to `mixed` throws away the typing that
+ * makes the SDK worth generating. `mixed` cannot be a union member, so a member without a
+ * PHP type of its own (inline object, intersection, unknown) forces the whole union to
+ * `mixed`. Members that map to the same PHP type collapse to one.
+ */
+export function phpUnionType(members: SchemaModel[], model: ApiModel, dateType: DateType): string {
+ const rendered: string[] = [];
+ for (const member of members) {
+ // `null` is handled by the caller's nullability check, never as a member here.
+ if (member.kind === 'null') continue;
+ const type = phpType(member, model, dateType);
+ if (type === 'mixed') return 'mixed';
+ // A nullable member inside a union contributes its bare type plus null.
+ const bare = type.startsWith('?') ? type.slice(1) : type;
+ if (!rendered.includes(bare)) rendered.push(bare);
+ if (type.startsWith('?') && !rendered.includes('null')) rendered.push('null');
+ }
+ if (rendered.length === 0) return 'mixed';
+ return rendered.join('|');
+}
+
+/**
+ * The element type behind a PHP type that erases it. `array` and `\Generator` are as
+ * specific as PHP's syntax gets, so the docblock carries what they hold — that is what
+ * static analysis and readers actually go by.
+ */
+export function phpElementType(
+ schema: SchemaModel | undefined,
+ model: ApiModel,
+ dateType: DateType
+): string | undefined {
+ if (schema === undefined) return undefined;
+ const bare = unwrapNullable(schema);
+ if (bare.kind === 'ref') {
+ const target = deref(bare, model);
+ // A named schema that IS an array (a collection alias) keeps its element type.
+ return classify(bare.name, model) === 'other'
+ ? phpElementType(target, model, dateType)
+ : undefined;
+ }
+ if (bare.kind !== 'array') return undefined;
+ const element = phpType(bare.items, model, dateType);
+ return element === 'mixed' ? undefined : element;
+}
diff --git a/packages/client-generator/src/generators/python/AGENTS.md b/packages/client-generator/src/generators/python/AGENTS.md
index 308cdaa5cf..5bf7882a10 100644
--- a/packages/client-generator/src/generators/python/AGENTS.md
+++ b/packages/client-generator/src/generators/python/AGENTS.md
@@ -84,8 +84,10 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a
- **Parity surface:** auth (bearer/basic/apiKey), retries with `Retry-After` + jittered
backoff, timeouts, idempotency keys, middleware, pagination (`_pages()` /
`_items()` + `aiter` mirrors), SSE (`iter_sse`/`aiter_sse`), multipart.
-- The runtime is hand-written in `runtime/python/*.py` and embedded as strings at prepare
+- The runtime is hand-written in `runtime/*.py` in this folder and embedded as strings at prepare
time — generator code never builds runtime logic from templates.
+ Under `--runtime module` the same sources are written as sibling `_*.py` files instead
+ (package-relative imports become sibling imports; the client star-imports each module).
- Authored ONLY with the neutral toolkit (`Printer`, naming, schema, pagination helpers) —
the dogfooding guard fails otherwise.
@@ -100,7 +102,7 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a
## The modify loop
1. Edit this skill: state the new behavior or decision.
-2. Change `index.ts` (and `runtime/python/*.py` if runtime behavior changes; then
+2. Change `index.ts` (and `runtime/*.py` if runtime behavior changes; then
`npm run prepare -w @redocly/client-generator` re-embeds).
3. Verify: `npm run compile`, then
`VITEST_SUITE=unit npx vitest run packages/client-generator/src/generators/__tests__/python.test.ts`
diff --git a/packages/client-generator/src/generators/python/client.ts b/packages/client-generator/src/generators/python/client.ts
new file mode 100644
index 0000000000..2d24c8404f
--- /dev/null
+++ b/packages/client-generator/src/generators/python/client.ts
@@ -0,0 +1,119 @@
+// The `client` stage: the `Servers` helper class and the `Client`/`AsyncClient`
+// classes that assemble the per-operation methods.
+
+import {
+ type ApiModel,
+ type DateType,
+ identifierFor,
+ jsonSuccessSchema,
+ paginationItemSchema,
+ type ServerModel,
+ serverUrlParts,
+ sseResponse,
+} from '@redocly/client-generator';
+import type { PythonPrinter } from '@redocly/client-generator/printers/python';
+
+import { fieldName, naming, operationIdents, PY } from './naming.ts';
+import { writeMethod } from './operations.ts';
+import { writePaginationWrappers } from './pagination.ts';
+import { pythonType } from './types.ts';
+
+/** The server URL as a Python expression: literals concatenated with declared-variable args. */
+function serverUrlExpression(server: ServerModel): string {
+ const parts = serverUrlParts(server).map((part) =>
+ part.kind === 'literal' ? naming.string(part.value) : fieldName(part.name).python
+ );
+ return parts.join(' + ');
+}
+
+/** One static method per declared server; server variables become keyword arguments. */
+export function writePythonServers(printer: PythonPrinter, model: ApiModel): void {
+ const servers = model.servers ?? [];
+ if (servers.length === 0) return;
+ const usedNames = new Set();
+ printer.block('class Servers:', () => {
+ printer.line(
+ '"""The declared servers; variables default to the values from the description."""'
+ );
+ printer.blank();
+ servers.forEach((server, index) => {
+ let name = identifierFor(server.description ?? `server${index + 1}`, {
+ style: 'snake',
+ reserved: PY,
+ });
+ if (usedNames.has(name)) name = `${name}_${index + 1}`;
+ usedNames.add(name);
+ const params = server.variables.map(
+ (variable) => `${fieldName(variable.name).python}: str = ${naming.string(variable.default)}`
+ );
+ if (index > 0) printer.blank();
+ printer.line('@staticmethod');
+ printer.block(`def ${name}(${params.join(', ')}) -> str:`, () => {
+ printer.line(`return ${serverUrlExpression(server)}`);
+ });
+ });
+ });
+ printer.blank();
+}
+
+export function writeClientClass(
+ printer: PythonPrinter,
+ model: ApiModel,
+ errorMode: 'throw' | 'result',
+ isAsync: boolean,
+ paginationSpecs: Map | undefined>,
+ serverUrl: string,
+ dateType: DateType
+): void {
+ const name = isAsync ? 'AsyncClient' : 'Client';
+ const httpType = isAsync ? 'httpx.AsyncClient' : 'httpx.Client';
+ printer.block(`class ${name}:`, () => {
+ printer.doc(`${isAsync ? 'Async ' : ''}client for ${model.title} (${model.version}).`);
+ printer.block(
+ `def __init__(self, server_url: str = ${naming.string(serverUrl)}, *, ` +
+ 'auth: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, ' +
+ 'timeout: Optional[float] = None, retry: Optional[Dict[str, Any]] = None, ' +
+ 'middleware: Optional[List[Any]] = None, idempotency_key: Any = None, ' +
+ `http_client: Optional[${httpType}] = None) -> None:`,
+ () => {
+ printer.line('self._server_url = server_url');
+ printer.line('self._auth = auth or {}');
+ printer.line('self._config: Dict[str, Any] = {');
+ printer.indent(() => {
+ printer.line('"headers": headers or {},');
+ printer.line('"timeout": timeout,');
+ printer.line('"retry": retry or {},');
+ printer.line('"middleware": middleware or [],');
+ printer.line('"idempotency_key": idempotency_key,');
+ });
+ printer.line('}');
+ printer.line(`self._http = http_client or ${httpType}()`);
+ }
+ );
+ printer.blank();
+ for (const { op, ident } of operationIdents(model)) {
+ writeMethod(printer, op, ident, errorMode, isAsync, dateType);
+ if (sseResponse(op) === undefined && (op.successResponseHeaders?.length ?? 0) > 0) {
+ writeMethod(printer, op, ident, errorMode, isAsync, dateType, model, true);
+ }
+ const spec = paginationSpecs.get(ident);
+ if (spec !== undefined) {
+ const success = jsonSuccessSchema(op);
+ const element = paginationItemSchema(
+ success,
+ typeof spec.items === 'string' ? spec.items : undefined,
+ model
+ );
+ writePaginationWrappers(
+ printer,
+ op,
+ ident,
+ isAsync,
+ element === undefined ? 'Any' : pythonType(element, dateType),
+ dateType
+ );
+ }
+ }
+ });
+ printer.blank();
+}
diff --git a/packages/client-generator/src/generators/python/descriptor.ts b/packages/client-generator/src/generators/python/descriptor.ts
new file mode 100644
index 0000000000..f13a7ca697
--- /dev/null
+++ b/packages/client-generator/src/generators/python/descriptor.ts
@@ -0,0 +1,48 @@
+// The `descriptor` stage: the wire-shape literals the embedded runtime routes by —
+// pagination specs, envelope-header coerce specs, and Python data literals.
+
+import {
+ type ApiModel,
+ headerCoerceType,
+ identifierFor,
+ type NeutralPaginationRule,
+ type OperationModel,
+} from '@redocly/client-generator';
+
+import { naming, PY } from './naming.ts';
+
+/** JSON → Python literal (dicts/lists/strings/numbers/bools/None). */
+export function pythonLiteral(value: unknown): string {
+ return naming.literal(value);
+}
+
+/** The resolved pagination rule mapped to the snake_case spec dict the embedded
+ * Python runtime consumes. */
+export function paginationSpec(
+ rule: NeutralPaginationRule | undefined
+): Record | undefined {
+ if (rule === undefined) return undefined;
+ return {
+ style: rule.style,
+ ...(rule.param !== undefined ? { param: rule.param } : {}),
+ ...(rule.nextCursor !== undefined ? { next_cursor: rule.nextCursor } : {}),
+ ...(rule.hasMore !== undefined ? { has_more: rule.hasMore } : {}),
+ ...(rule.limitParam !== undefined ? { limit_param: rule.limitParam } : {}),
+ ...(rule.items !== undefined ? { items: rule.items } : {}),
+ };
+}
+
+/** Declared response headers as runtime coerce specs: `("wire-name", "snake_key", "type")`. */
+export function envelopeHeaderSpecs(op: OperationModel, model: ApiModel): string {
+ const used = new Set();
+ const specs = (op.successResponseHeaders ?? []).map((header) => {
+ const base = identifierFor(header.name, { style: 'snake', reserved: PY });
+ let key = base;
+ let suffix = 2;
+ while (used.has(key)) key = `${base}_${suffix++}`;
+ used.add(key);
+ const type = headerCoerceType(header.schema, model);
+ return `(${naming.string(header.name)}, ${naming.string(key)}, ${naming.string(type)})`;
+ });
+ return `[${specs.join(', ')}]`;
+}
diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts
index 20cb9e99fc..0aa962b8f1 100644
--- a/packages/client-generator/src/generators/python/index.ts
+++ b/packages/client-generator/src/generators/python/index.ts
@@ -2,101 +2,33 @@
// authored the way the AGENTS.md skill teaches users' agents to author theirs:
// with the language-neutral toolkit only (Printer + schema/naming helpers).
// A guard test pins that this module never imports the TS emitter toolkit.
+// One file per pipeline stage (ADR-0020); this entry assembles them.
import {
- Printer,
- paginationRuleFor,
- renderReferencePage,
- schemaAtPointer,
- discriminatorCases,
- docText,
- enumValues,
- flattenAllOf,
- headerCoerceType,
+ type CodeSample,
+ type Generator,
+ type GeneratorOptionsSchema,
identifierFor,
- isNullable,
- RESERVED_WORDS,
- uniqueIdentifiers,
- unwrapNullable,
- type DateType,
-} from '../../authoring/index.js';
-import { PYTHON_RUNTIME_SOURCES } from '../../emitters/python-runtime-sources.js';
-import type {
- ApiModel,
- OperationModel,
- PropertyModel,
- SchemaModel,
- ServerModel,
-} from '../../intermediate-representation/model.js';
-import type { CodeSample, Generator, GeneratorOptionsSchema, SampleContext } from '../types.js';
-
-const PY = RESERVED_WORDS.python;
-
-/** A named schema's Python class name. */
-function className(name: string): string {
- return identifierFor(name, { style: 'pascal', reserved: PY });
-}
-
-/** A field/parameter name, with the wire name preserved when sanitization renames it. */
-function fieldName(name: string): { python: string; renamed: boolean } {
- const python = identifierFor(name, { style: 'snake', reserved: PY });
- return { python, renamed: python !== name };
-}
-
-/** The Python type annotation for a schema (anonymous complex shapes collapse to Any-ish). */
-export function pythonType(schema: SchemaModel, dateType: DateType = 'string'): string {
- if (isNullable(schema)) {
- return `Optional[${pythonType(unwrapNullable(schema), dateType)}]`;
- }
- switch (schema.kind) {
- case 'scalar':
- // `dateType: Date` annotates date/date-time as stdlib objects; `_decode.py`
- // converts them from and to ISO strings on the wire.
- if (dateType === 'Date' && schema.scalar === 'string') {
- if (schema.metadata?.format === 'date-time') return 'datetime';
- if (schema.metadata?.format === 'date') return 'date';
- }
- return { string: 'str', integer: 'int', number: 'float', boolean: 'bool' }[schema.scalar];
- case 'array':
- return `List[${pythonType(schema.items, dateType)}]`;
- case 'record':
- return `Dict[str, ${pythonType(schema.value, dateType)}]`;
- case 'ref':
- return className(schema.name);
- case 'literal':
- return `Literal[${JSON.stringify(schema.value)}]`;
- case 'enum':
- // Anonymous (inline) enums keep the wire scalar; only NAMED enums get classes.
- return { string: 'str', integer: 'int', number: 'float', boolean: 'bool' }[schema.scalar];
- case 'union':
- return `Union[${schema.members.map((member) => pythonType(member, dateType)).join(', ')}]`;
- case 'null':
- return 'None';
- case 'omit':
- // Python has no Omit; the base class is the honest annotation (readOnly
- // fields are server-managed and simply absent on requests).
- return className(schema.base);
- case 'object':
- case 'intersection':
- case 'unknown':
- return 'Any';
- }
-}
-
-function writeDocstring(printer: Printer, description?: string): void {
- const lines = docText(description);
- if (lines.length === 0) return;
- if (lines.length === 1) {
- printer.line(`"""${lines[0]}"""`);
- return;
- }
- printer.line(`"""${lines[0]}`);
- for (const line of lines.slice(1)) printer.line(line);
- printer.line('"""');
-}
+ type OperationModel,
+ renderReferencePage,
+ type SampleContext,
+ securityRequirements,
+} from '@redocly/client-generator';
+import { PythonPrinter } from '@redocly/client-generator/printers/python';
+import { PYTHON_RUNTIME_SOURCES } from '@redocly/client-generator/runtime-sources';
+
+import { writeClientClass, writePythonServers } from './client.ts';
+import { paginationSpec, pythonLiteral } from './descriptor.ts';
+import {
+ discriminatorRegistrations,
+ pydanticDiscriminators,
+ renderPythonModels,
+ type PythonModels,
+} from './models.ts';
+import { operationIdents, PY } from './naming.ts';
-/** The model style the generator emits: plain dataclasses, or pydantic `BaseModel`s. */
-export type PythonModels = 'dataclass' | 'pydantic';
+export { renderPythonModels, type PythonModels } from './models.ts';
+export { pythonType } from './types.ts';
export const pythonOptions: GeneratorOptionsSchema = {
type: 'object',
@@ -111,692 +43,6 @@ export const pythonOptions: GeneratorOptionsSchema = {
additionalProperties: false,
};
-/** The wire property and value a union's discriminator mapping pins on one member class. */
-type DiscriminatorPin = { property: string; value: string };
-
-/**
- * Under `models: pydantic` the decoder hands a whole object tree to `model_validate`, so a
- * union nested in a model is resolved by pydantic and never reaches the `DISCRIMINATORS`
- * table. Pydantic resolves it correctly when the annotation carries the discriminator, which
- * it accepts only if every member types that property as a `Literal` — and the mapping
- * already pins one value per member. This pass works out which unions qualify: every member
- * must declare the property, and no member may be pinned to two different values (a schema
- * reused by two unions).
- */
-function pydanticDiscriminators(model: ApiModel): {
- pins: Map;
- unions: Map;
-} {
- const pins = new Map();
- const conflicted = new Set();
- const candidates: Array<{ name: string; property: string; members: string[] }> = [];
- for (const { name, schema } of model.schemas) {
- const cases = discriminatorCases(schema, model);
- if (cases === undefined) continue;
- const declares = cases.cases.every(
- (entry) =>
- flattenAllOf(entry.schema, model)?.properties.some(
- (property) => property.name === cases.property
- ) === true
- );
- if (!declares) continue;
- for (const entry of cases.cases) {
- const existing = pins.get(entry.schemaName);
- if (existing !== undefined && existing.value !== entry.value) {
- conflicted.add(entry.schemaName);
- continue;
- }
- pins.set(entry.schemaName, { property: cases.property, value: entry.value });
- }
- candidates.push({
- name,
- property: cases.property,
- members: cases.cases.map((entry) => entry.schemaName),
- });
- }
- const unions = new Map();
- for (const candidate of candidates) {
- if (candidate.members.some((member) => conflicted.has(member))) continue;
- unions.set(candidate.name, fieldName(candidate.property).python);
- }
- for (const member of conflicted) pins.delete(member);
- return { pins, unions };
-}
-
-/**
- * The argument names every request method declares itself. A parameter named after one of
- * them takes a suffixed binding instead, so the slot keeps its meaning.
- */
-const METHOD_ARG_SLOTS = ['self', 'body', 'headers', 'timeout', 'retry', 'idempotency_key'];
-
-function writeDataclass(
- printer: Printer,
- name: string,
- properties: PropertyModel[],
- dateType: DateType,
- models: PythonModels,
- description?: string,
- /** The discriminator value this class is mapped to, pinned as a `Literal` (pydantic). */
- pinned?: DiscriminatorPin
-): void {
- const pydantic = models === 'pydantic';
- if (!pydantic) printer.line('@dataclass');
- const header = pydantic ? `class ${className(name)}(BaseModel):` : `class ${className(name)}:`;
- printer.block(header, () => {
- writeDocstring(printer, description);
- // A wire name that is not a legal field name travels as an alias, so the model
- // accepts both spellings; without this, populating by field name would fail.
- if (pydantic) {
- printer.line('model_config = ConfigDict(populate_by_name=True)');
- printer.blank();
- }
- // Required fields first — a dataclass field without a default may not follow one with.
- const ordered = [
- ...properties.filter((property) => property.required),
- ...properties.filter((property) => !property.required),
- ];
- const fieldMap: Array<[string, string]> = [];
- if (ordered.length === 0) printer.line('pass');
- for (const property of ordered) {
- const { python, renamed } = fieldName(property.name);
- if (renamed && !pydantic) fieldMap.push([python, property.name]);
- const alias = renamed && pydantic ? `alias=${JSON.stringify(property.name)}` : undefined;
- const baseType =
- pinned?.property === property.name
- ? `Literal[${JSON.stringify(pinned.value)}]`
- : pythonType(property.schema, dateType);
- if (property.required) {
- const value = alias === undefined ? '' : ` = Field(${alias})`;
- printer.line(`${python}: ${baseType}${value}`);
- } else {
- const optional = baseType.startsWith('Optional[') ? baseType : `Optional[${baseType}]`;
- const value = alias === undefined ? 'None' : `Field(default=None, ${alias})`;
- printer.line(`${python}: ${optional} = ${value}`);
- }
- }
- if (fieldMap.length > 0) {
- printer.blank();
- printer.line('# Python field name -> wire (JSON) name, for (de)serialization.');
- const entries = fieldMap.map(([py, wire]) => `"${py}": ${JSON.stringify(wire)}`).join(', ');
- printer.line(`_field_map: ClassVar[Dict[str, str]] = {${entries}}`);
- }
- });
- printer.blank();
- printer.blank();
-}
-
-/** Render every named schema: Enum classes, dataclasses (allOf flattened), union aliases. */
-export function renderPythonModels(
- model: ApiModel,
- dateType: DateType = 'string',
- models: PythonModels = 'dataclass'
-): string {
- const printer = new Printer(' ');
- const { pins, unions } =
- models === 'pydantic'
- ? pydanticDiscriminators(model)
- : { pins: new Map(), unions: new Map() };
- printer.line('from __future__ import annotations');
- printer.blank();
- if (models === 'dataclass') printer.line('from dataclasses import dataclass');
- printer.line('from enum import Enum');
- // `ClassVar` types the `_field_map` of a dataclass model, which pydantic mode
- // replaces with field aliases — importing it there would be an unused import.
- const typingNames = [
- 'Any',
- 'AsyncIterator',
- 'Dict',
- 'Iterator',
- 'List',
- 'Literal',
- 'Optional',
- 'Tuple',
- 'Union',
- ];
- if (models === 'dataclass') typingNames.splice(2, 0, 'ClassVar');
- if (unions.size > 0) typingNames.unshift('Annotated');
- printer.line(`from typing import ${typingNames.join(', ')}`);
- if (models === 'pydantic') printer.line('from pydantic import BaseModel, ConfigDict, Field');
- // Only under `dateType: Date` — an unused import in every other client would be noise.
- if (dateType === 'Date') printer.line('from datetime import date, datetime');
- printer.blank();
- printer.blank();
-
- const aliases: Array<() => void> = [];
- for (const { name, schema } of model.schemas) {
- const asEnum = enumValues(schema);
- if (asEnum !== undefined) {
- const base = asEnum.scalar === 'string' ? 'str, Enum' : 'int, Enum';
- printer.block(`class ${className(name)}(${base}):`, () => {
- writeDocstring(printer, schema.description);
- asEnum.values.forEach((value, index) => {
- printer.line(`${asEnum.memberNames[index]} = ${JSON.stringify(value)}`);
- });
- });
- printer.blank();
- printer.blank();
- continue;
- }
- if (schema.kind === 'object' || schema.kind === 'intersection') {
- const flat = flattenAllOf(schema, model);
- if (flat !== undefined) {
- writeDataclass(
- printer,
- name,
- flat.properties,
- dateType,
- models,
- flat.description ?? schema.description,
- pins.get(name)
- );
- continue;
- }
- }
- // Everything else (unions, scalar aliases, records) becomes a module-level alias,
- // emitted AFTER the classes it references so the assignment evaluates.
- aliases.push(() => {
- const cases = discriminatorCases(schema, model);
- if (cases !== undefined) {
- const table = cases.cases
- .map((entry) => `${entry.value} -> ${className(entry.schemaName)}`)
- .join(', ');
- printer.line(`# Discriminated by "${cases.property}": ${table}`);
- }
- const field = unions.get(name);
- const union =
- field === undefined
- ? pythonType(schema, dateType)
- : `Annotated[${pythonType(schema, dateType)}, Field(discriminator=${JSON.stringify(field)})]`;
- printer.line(`${className(name)} = ${union}`);
- printer.blank();
- });
- }
- for (const emit of aliases) emit();
- return printer.toString();
-}
-
-/** The server URL as a Python expression: literals concatenated with declared-variable args. */
-function serverUrlExpression(server: ServerModel): string {
- const declared = new Set(server.variables.map((variable) => variable.name));
- const parts: string[] = [];
- let literal = '';
- let rest = server.url;
- const template = /\{([^{}]+)\}/;
- for (let match = template.exec(rest); match !== null; match = template.exec(rest)) {
- literal += rest.slice(0, match.index);
- if (declared.has(match[1])) {
- if (literal !== '') parts.push(JSON.stringify(literal));
- literal = '';
- parts.push(fieldName(match[1]).python);
- } else {
- // An undeclared variable has nothing to substitute; keep its placeholder visible.
- literal += match[0];
- }
- rest = rest.slice(match.index + match[0].length);
- }
- literal += rest;
- if (literal !== '' || parts.length === 0) parts.push(JSON.stringify(literal));
- return parts.join(' + ');
-}
-
-/** One static method per declared server; server variables become keyword arguments. */
-function writePythonServers(printer: Printer, model: ApiModel): void {
- const servers = model.servers ?? [];
- if (servers.length === 0) return;
- const usedNames = new Set();
- printer.block('class Servers:', () => {
- printer.line(
- '"""The declared servers; variables default to the values from the description."""'
- );
- printer.blank();
- servers.forEach((server, index) => {
- let name = identifierFor(server.description ?? `server${index + 1}`, {
- style: 'snake',
- reserved: PY,
- });
- if (usedNames.has(name)) name = `${name}_${index + 1}`;
- usedNames.add(name);
- const params = server.variables.map(
- (variable) =>
- `${fieldName(variable.name).python}: str = ${JSON.stringify(variable.default)}`
- );
- if (index > 0) printer.blank();
- printer.line('@staticmethod');
- printer.block(`def ${name}(${params.join(', ')}) -> str:`, () => {
- printer.line(`return ${serverUrlExpression(server)}`);
- });
- });
- });
- printer.blank();
-}
-
-/**
- * `DISCRIMINATORS[Pet] = ("petType", {"cat": Cat, ...})` registration lines, which `decode`
- * dispatches through. A union whose annotation already carries the discriminator is left
- * out: pydantic resolves it at any depth, and the `Literal` on each member makes the
- * decoder's member probe exact.
- */
-function discriminatorRegistrations(model: ApiModel, annotated: Set): string[] {
- const lines: string[] = [];
- for (const { name, schema } of model.schemas) {
- if (annotated.has(name)) continue;
- const cases = discriminatorCases(schema, model);
- if (cases === undefined) continue;
- const mapping = cases.cases
- .map((entry) => `${JSON.stringify(entry.value)}: ${className(entry.schemaName)}`)
- .join(', ');
- lines.push(
- `DISCRIMINATORS[${className(name)}] = (${JSON.stringify(cases.property)}, {${mapping}})`
- );
- }
- return lines;
-}
-
-/** The operation's primary JSON success schema, or undefined for void/no-body ops. */
-function successSchema(op: OperationModel): SchemaModel | undefined {
- return op.successResponses.find((r) => r.contentType.toLowerCase().includes('json'))?.schema;
-}
-
-/** Security specs for the descriptor dict — the wire shape resolve_auth consumes. */
-function securitySpecs(op: OperationModel, model: ApiModel): unknown[][] {
- return op.security
- .map((alternative) =>
- alternative.flatMap((key): Array> => {
- const scheme = model.securitySchemes.find((s) => s.key === key);
- if (scheme === undefined) return [];
- if (scheme.kind === 'bearer' || scheme.kind === 'basic') {
- return [{ scheme: key, kind: scheme.kind }];
- }
- if (scheme.kind === 'apiKeyHeader') {
- return [{ scheme: key, kind: 'apiKey', name: scheme.headerName, in: 'header' }];
- }
- if (scheme.kind === 'apiKeyQuery') {
- return [{ scheme: key, kind: 'apiKey', name: scheme.paramName, in: 'query' }];
- }
- return [{ scheme: key, kind: 'apiKey', name: scheme.cookieName, in: 'cookie' }];
- })
- )
- .filter((alternative) => alternative.length > 0);
-}
-
-/** JSON → Python literal (dicts/lists/strings/numbers/bools/None). */
-function pythonLiteral(value: unknown): string {
- if (value === null || value === undefined) return 'None';
- if (value === true) return 'True';
- if (value === false) return 'False';
- if (typeof value === 'number') return String(value);
- if (typeof value === 'string') return JSON.stringify(value);
- if (Array.isArray(value)) return `[${value.map(pythonLiteral).join(', ')}]`;
- const entries = Object.entries(value as Record)
- .map(([key, entry]) => `${JSON.stringify(key)}: ${pythonLiteral(entry)}`)
- .join(', ');
- return `{${entries}}`;
-}
-
-/** Every operation with its collision-free snake_case Python method name. */
-function operationIdents(model: ApiModel): Array<{ op: OperationModel; ident: string }> {
- const used = new Set();
- const out: Array<{ op: OperationModel; ident: string }> = [];
- for (const service of model.services) {
- for (const op of service.operations) {
- let ident = identifierFor(op.name, { style: 'snake', reserved: PY });
- let suffix = 2;
- while (used.has(ident))
- ident = `${identifierFor(op.name, { style: 'snake', reserved: PY })}_${suffix++}`;
- used.add(ident);
- out.push({ op, ident });
- }
- }
- return out;
-}
-
-/** The op's SSE success response, when it streams text/event-stream. */
-function sseResponse(op: OperationModel) {
- return op.successResponses.find((r) => r.contentType.toLowerCase().includes('text/event-stream'));
-}
-
-function isMultipart(op: OperationModel): boolean {
- return op.requestBody?.contentType.toLowerCase().includes('multipart') ?? false;
-}
-
-/** The neutral pagination rule mapped to the snake_case spec dict the embedded
- * Python runtime consumes. */
-function paginationSpec(
- op: OperationModel,
- emit: { pagination?: Record }
-): Record | undefined {
- const rule = paginationRuleFor(op, emit.pagination);
- if (rule === undefined) return undefined;
- return {
- style: rule.style,
- ...(rule.param !== undefined ? { param: rule.param } : {}),
- ...(rule.nextCursor !== undefined ? { next_cursor: rule.nextCursor } : {}),
- ...(rule.hasMore !== undefined ? { has_more: rule.hasMore } : {}),
- ...(rule.limitParam !== undefined ? { limit_param: rule.limitParam } : {}),
- ...(rule.items !== undefined ? { items: rule.items } : {}),
- };
-}
-
-/** Declared response headers as runtime coerce specs: `("wire-name", "snake_key", "type")`. */
-function envelopeHeaderSpecs(op: OperationModel, model: ApiModel): string {
- const used = new Set();
- const specs = (op.successResponseHeaders ?? []).map((header) => {
- const base = identifierFor(header.name, { style: 'snake', reserved: PY });
- let key = base;
- let suffix = 2;
- while (used.has(key)) key = `${base}_${suffix++}`;
- used.add(key);
- const type = headerCoerceType(header.schema, model);
- return `(${JSON.stringify(header.name)}, ${JSON.stringify(key)}, ${JSON.stringify(type)})`;
- });
- return `[${specs.join(', ')}]`;
-}
-
-function writeMethod(
- printer: Printer,
- op: OperationModel,
- ident: string,
- errorMode: 'throw' | 'result',
- isAsync: boolean,
- dateType: DateType,
- model?: ApiModel,
- envelope = false
-): void {
- // Every parameter is a separate argument, so path and query names share one namespace
- // with the slots this method declares itself. `uniqueIdentifiers` moves a repeat aside
- // (`id`, `id_2`) — a description may legally use one name in two locations, and a
- // signature that declared it twice would not even parse.
- const argNames = uniqueIdentifiers(
- [...op.pathParams, ...op.queryParams].map((param) => param.name),
- { style: 'snake', reserved: PY, taken: METHOD_ARG_SLOTS }
- );
- const pathArgs = op.pathParams.map((param, index) => ({ param, python: argNames[index] }));
- const queryArgs = op.queryParams.map((param, index) => ({
- param,
- python: argNames[op.pathParams.length + index],
- }));
- const positional = pathArgs.map(
- ({ param, python }) => `${python}: ${pythonType(param.schema, dateType)}`
- );
- const bodyArg = op.requestBody ? [`body: ${pythonType(op.requestBody.schema, dateType)}`] : [];
- const kwargs = [
- ...queryArgs.map(({ param, python }) => {
- const annotation = pythonType(param.schema, dateType);
- const optional = annotation.startsWith('Optional[') ? annotation : `Optional[${annotation}]`;
- return `${python}: ${optional} = None`;
- }),
- 'headers: Optional[Dict[str, str]] = None',
- 'timeout: Optional[float] = None',
- 'retry: Optional[Dict[str, Any]] = None',
- 'idempotency_key: Any = None',
- ];
- const success = successSchema(op);
- const sse = sseResponse(op);
- const returns = envelope
- ? `Envelope[${success === undefined ? 'None' : pythonType(success, dateType)}]`
- : sse !== undefined
- ? `${isAsync ? 'AsyncIterator' : 'Iterator'}[ServerSentEvent]`
- : errorMode === 'result'
- ? 'Result'
- : success === undefined
- ? 'None'
- : pythonType(success, dateType);
- // Streaming methods are plain defs returning an (async) iterator — an `async def`
- // would force awaiting the call before iterating it.
- const prefix = isAsync && sse === undefined ? 'async def' : 'def';
- const awaitKw = isAsync ? 'await ' : '';
- const sendFn = isAsync ? 'send_async' : 'send';
- const signature = ['self', ...positional, ...bodyArg, '*', ...kwargs].join(', ');
- const defName = envelope ? `${ident}_with_headers` : ident;
- printer.block(`${prefix} ${defName}(${signature}) -> ${returns}:`, () => {
- writeDocstring(
- printer,
- envelope
- ? `Like ${ident}(), returning an Envelope with the declared response headers.`
- : op.summary
- );
- printer.line(`op = _OPERATIONS["${ident}"]`);
- printer.line('auth_headers, auth_query = resolve_auth(op.get("security") or [], self._auth)');
- printer.line('params: Dict[str, Any] = dict(auth_query)');
- for (const { param, python } of queryArgs) {
- printer.block(`if ${python} is not None:`, () => {
- printer.line(`params[${JSON.stringify(param.name)}] = encode(${python})`);
- });
- }
- const pathDict = pathArgs
- .map(({ param, python }) => `${JSON.stringify(param.name)}: ${python}`)
- .join(', ');
- printer.line(`url = build_url(self._server_url, op["path"], {${pathDict}})`);
- if (sse !== undefined) {
- const dataKind = sse.schema !== undefined && sse.schema.kind !== 'unknown' ? 'json' : 'text';
- printer.block('def _open(extra_headers: Dict[str, str]):', () => {
- printer.line(
- 'return self._http.stream(op["method"], url, ' +
- 'headers={**auth_headers, **(headers or {}), **extra_headers}, params=params, timeout=timeout)'
- );
- });
- printer.line(`return ${isAsync ? 'aiter_sse' : 'iter_sse'}(_open, data_kind="${dataKind}")`);
- return;
- }
- if (isMultipart(op)) printer.line('form_data, form_files = to_multipart(body)');
- const bodyKw = op.requestBody
- ? isMultipart(op)
- ? ', data=form_data, files=form_files'
- : ', json_body=encode(body)'
- : '';
- printer.line(
- `response = ${awaitKw}${sendFn}(self._http, self._config, op, url, method=op["method"], ` +
- `headers={**auth_headers, **(headers or {})}, params=params${bodyKw}, ` +
- 'timeout=timeout, retry=retry, idempotency_key=idempotency_key)'
- );
- const decoded =
- success === undefined
- ? 'None'
- : `decode(${pythonType(success, dateType)}, _safe_json(response))`;
- if (envelope) {
- printer.block('if not response.is_success:', () => {
- printer.line(
- 'raise ApiError(url, response.status_code, response.reason_phrase, _safe_json(response))'
- );
- });
- printer.line(
- `return Envelope(data=${decoded}, headers=read_envelope_headers(response, ${envelopeHeaderSpecs(op, model!)}), response=response)`
- );
- } else if (errorMode === 'result') {
- printer.block('if not response.is_success:', () => {
- printer.line('return Result(data=None, error=_safe_json(response), response=response)');
- });
- printer.line(`return Result(data=${decoded}, error=None, response=response)`);
- } else {
- printer.block('if not response.is_success:', () => {
- printer.line(
- 'raise ApiError(url, response.status_code, response.reason_phrase, _safe_json(response))'
- );
- });
- printer.line(success === undefined ? 'return None' : `return ${decoded}`);
- }
- });
- printer.blank();
-}
-
-/** `_pages` / `_items` iterator methods for a paginated operation. */
-function writePaginationWrappers(
- printer: Printer,
- op: OperationModel,
- ident: string,
- isAsync: boolean,
- itemType: string,
- dateType: DateType
-): void {
- const success = successSchema(op);
- const pageType = success === undefined ? 'Any' : pythonType(success, dateType);
- // The iterators take the same arguments as the operation itself, computed the same way,
- // so a name the method moved aside (`id_2`) is the same name here — copying a call from
- // one to the other has to keep working. Path values are substituted, not dropped.
- const argNames = uniqueIdentifiers(
- [...op.pathParams, ...op.queryParams].map((param) => param.name),
- { style: 'snake', reserved: PY, taken: METHOD_ARG_SLOTS }
- );
- const pathArgs = op.pathParams.map((param, index) => ({ param, python: argNames[index] }));
- const queryArgs = op.queryParams.map((param, index) => ({
- param,
- python: argNames[op.pathParams.length + index],
- }));
- const positional = pathArgs.map(
- ({ param, python }) => `${python}: ${pythonType(param.schema, dateType)}`
- );
- const kwargs = [
- ...queryArgs.map(({ param, python }) => {
- const annotation = pythonType(param.schema);
- const optional = annotation.startsWith('Optional[') ? annotation : `Optional[${annotation}]`;
- return `${python}: ${optional} = None`;
- }),
- 'headers: Optional[Dict[str, str]] = None',
- 'timeout: Optional[float] = None',
- 'retry: Optional[Dict[str, Any]] = None',
- ];
- const signature = ['self', ...positional, '*', ...kwargs].join(', ');
- const iterType = isAsync ? 'AsyncIterator' : 'Iterator';
- const pagesFn = isAsync ? 'aiter_pages' : 'iter_pages';
- const itemsFn = isAsync ? 'aiter_items' : 'iter_items';
-
- const writeCallClosure = () => {
- printer.line('base: Dict[str, Any] = {}');
- for (const { param, python } of queryArgs) {
- printer.block(`if ${python} is not None:`, () => {
- printer.line(`base[${JSON.stringify(param.name)}] = encode(${python})`);
- });
- }
- const prefix = isAsync ? 'async def' : 'def';
- const awaitKw = isAsync ? 'await ' : '';
- printer.block(`${prefix} _page(page_params: Dict[str, Any]) -> Tuple[Any, Any]:`, () => {
- printer.line('auth_headers, auth_query = resolve_auth(op.get("security") or [], self._auth)');
- const pathDict = pathArgs
- .map(({ param, python }) => `${JSON.stringify(param.name)}: ${python}`)
- .join(', ');
- printer.line(`url = build_url(self._server_url, op["path"], {${pathDict}})`);
- printer.line(
- `response = ${awaitKw}${isAsync ? 'send_async' : 'send'}(self._http, self._config, op, url, method=op["method"], ` +
- 'headers={**auth_headers, **(headers or {})}, params={**page_params, **auth_query}, ' +
- 'timeout=timeout, retry=retry)'
- );
- printer.block('if not response.is_success:', () => {
- printer.line(
- 'raise ApiError(url, response.status_code, response.reason_phrase, _safe_json(response))'
- );
- });
- printer.line('return _safe_json(response), response');
- });
- };
-
- // pages: raw page JSON decoded into the page model per page.
- if (isAsync) {
- printer.block(`async def ${ident}_pages(${signature}) -> ${iterType}[${pageType}]:`, () => {
- printer.line(`op = _OPERATIONS["${ident}"]`);
- writeCallClosure();
- printer.block(`async for page in ${pagesFn}(_page, op["pagination"], base):`, () => {
- printer.line(pageType === 'Any' ? 'yield page' : `yield decode(${pageType}, page)`);
- });
- });
- printer.blank();
- printer.block(`async def ${ident}_items(${signature}) -> ${iterType}[${itemType}]:`, () => {
- printer.line(`op = _OPERATIONS["${ident}"]`);
- writeCallClosure();
- printer.block(`async for item in ${itemsFn}(_page, op["pagination"], base):`, () => {
- printer.line(itemType === 'Any' ? 'yield item' : `yield decode(${itemType}, item)`);
- });
- });
- } else {
- printer.block(`def ${ident}_pages(${signature}) -> ${iterType}[${pageType}]:`, () => {
- printer.line(`op = _OPERATIONS["${ident}"]`);
- writeCallClosure();
- printer.line(
- pageType === 'Any'
- ? `return ${pagesFn}(_page, op["pagination"], base)`
- : `return (decode(${pageType}, page) for page in ${pagesFn}(_page, op["pagination"], base))`
- );
- });
- printer.blank();
- printer.block(`def ${ident}_items(${signature}) -> ${iterType}[${itemType}]:`, () => {
- printer.line(`op = _OPERATIONS["${ident}"]`);
- writeCallClosure();
- printer.line(
- itemType === 'Any'
- ? `return ${itemsFn}(_page, op["pagination"], base)`
- : `return (decode(${itemType}, item) for item in ${itemsFn}(_page, op["pagination"], base))`
- );
- });
- }
- printer.blank();
-}
-
-function writeClientClass(
- printer: Printer,
- model: ApiModel,
- errorMode: 'throw' | 'result',
- isAsync: boolean,
- paginationSpecs: Map | undefined>,
- serverUrl: string,
- dateType: DateType
-): void {
- const name = isAsync ? 'AsyncClient' : 'Client';
- const httpType = isAsync ? 'httpx.AsyncClient' : 'httpx.Client';
- printer.block(`class ${name}:`, () => {
- writeDocstring(
- printer,
- `${isAsync ? 'Async ' : ''}client for ${model.title} (${model.version}).`
- );
- printer.block(
- `def __init__(self, server_url: str = ${JSON.stringify(serverUrl)}, *, ` +
- 'auth: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, ' +
- 'timeout: Optional[float] = None, retry: Optional[Dict[str, Any]] = None, ' +
- 'middleware: Optional[List[Any]] = None, idempotency_key: Any = None, ' +
- `http_client: Optional[${httpType}] = None) -> None:`,
- () => {
- printer.line('self._server_url = server_url');
- printer.line('self._auth = auth or {}');
- printer.line('self._config: Dict[str, Any] = {');
- printer.indent(() => {
- printer.line('"headers": headers or {},');
- printer.line('"timeout": timeout,');
- printer.line('"retry": retry or {},');
- printer.line('"middleware": middleware or [],');
- printer.line('"idempotency_key": idempotency_key,');
- });
- printer.line('}');
- printer.line(`self._http = http_client or ${httpType}()`);
- }
- );
- printer.blank();
- for (const { op, ident } of operationIdents(model)) {
- writeMethod(printer, op, ident, errorMode, isAsync, dateType);
- if (sseResponse(op) === undefined && (op.successResponseHeaders?.length ?? 0) > 0) {
- writeMethod(printer, op, ident, errorMode, isAsync, dateType, model, true);
- }
- const spec = paginationSpecs.get(ident);
- if (spec !== undefined) {
- const success = successSchema(op);
- // Resolve the items ARRAY, then take its raw element schema — a `ref`
- // element keeps its name (a deref'd result would type as Any).
- const itemsArray =
- success !== undefined && typeof spec.items === 'string'
- ? schemaAtPointer(success, spec.items, model)
- : undefined;
- const element = itemsArray?.kind === 'array' ? itemsArray.items : undefined;
- writePaginationWrappers(
- printer,
- op,
- ident,
- isAsync,
- element === undefined ? 'Any' : pythonType(element, dateType),
- dateType
- );
- }
- }
- });
- printer.blank();
-}
-
/**
* The output path with an IMPORTABLE module name. The `--output` stem follows the
* TypeScript convention (`openapi.client.ts`), and `openapi.client.py` cannot be
@@ -812,12 +58,19 @@ function pythonModulePath(outputPath: string): string {
}
/** The whole generated file: header, models, embedded runtime, descriptors, clients. */
-export const pythonGenerator: Generator = ({ model, outputPath, emit, options }) => {
+export const pythonGenerator: Generator = ({
+ model,
+ output,
+ banner,
+ emit,
+ options,
+ pagination,
+}) => {
const errorMode = emit.errorMode ?? 'throw';
const dateType = emit.dateType ?? 'string';
const models = (options?.models as PythonModels | undefined) ?? 'dataclass';
const pydantic = models === 'pydantic' ? pydanticDiscriminators(model) : undefined;
- const printer = new Printer(' ');
+ const printer = new PythonPrinter();
printer.line(
`# Generated by @redocly/client-generator (python) from "${model.title}" ${model.version}.`
);
@@ -835,20 +88,31 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit, options })
printer.blank();
writePythonServers(printer, model);
- // The embedded runtime, stitched into one module: `from __future__` may appear
- // only at the top of a file, and the intra-runtime relative imports resolve to
- // this same file — both are dropped; duplicate stdlib imports are legal Python.
- printer.line('# ─── Embedded runtime (@redocly/client-generator python runtime) ───');
- for (const source of Object.values(PYTHON_RUNTIME_SOURCES)) {
- const stitched = source
- .split('\n')
- .filter((line) => !line.startsWith('from __future__') && !line.startsWith('from ._'))
- .join('\n')
- .trim();
- printer.line(stitched);
+ if (emit.runtime === 'module') {
+ // The runtime lives in real sibling modules; star imports rebind the same
+ // public names the inline stitching would have defined at this position.
+ printer.line('# ─── Runtime (real modules beside this file, written by the same run) ───');
+ for (const name of Object.keys(PYTHON_RUNTIME_SOURCES)) {
+ printer.line(`from ${name.replace(/\.py$/, '')} import *`);
+ }
+ printer.blank();
+ printer.blank();
+ } else {
+ // The embedded runtime, stitched into one module: `from __future__` may appear
+ // only at the top of a file, and the intra-runtime relative imports resolve to
+ // this same file — both are dropped; duplicate stdlib imports are legal Python.
+ printer.line('# ─── Embedded runtime (@redocly/client-generator python runtime) ───');
+ for (const source of Object.values(PYTHON_RUNTIME_SOURCES)) {
+ const stitched = source
+ .split('\n')
+ .filter((line) => !line.startsWith('from __future__') && !line.startsWith('from ._'))
+ .join('\n')
+ .trim();
+ printer.line(stitched);
+ printer.blank();
+ }
printer.blank();
}
- printer.blank();
const registrations = discriminatorRegistrations(model, new Set(pydantic?.unions.keys()));
if (registrations.length > 0) {
printer.line('# Discriminated unions dispatch by their property inside decode().');
@@ -868,10 +132,7 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit, options })
// The wire-shape descriptor table the runtime routes by.
const paginationSpecs = new Map | undefined>();
for (const { op, ident } of operationIdents(model)) {
- paginationSpecs.set(
- ident,
- paginationSpec(op, emit as { pagination?: Record })
- );
+ paginationSpecs.set(ident, paginationSpec(pagination?.get(op.name)?.spec));
}
printer.line('_OPERATIONS = {');
printer.indent(() => {
@@ -880,7 +141,9 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit, options })
id: op.specName ?? op.name,
method: op.method.toUpperCase(),
path: op.path,
- ...(securitySpecs(op, model).length > 0 ? { security: securitySpecs(op, model) } : {}),
+ ...(securityRequirements(op, model).length > 0
+ ? { security: securityRequirements(op, model) }
+ : {}),
...(paginationSpecs.get(ident) !== undefined
? { pagination: paginationSpecs.get(ident) }
: {}),
@@ -897,7 +160,21 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit, options })
writeClientClass(printer, model, errorMode, false, paginationSpecs, serverUrl, dateType);
writeClientClass(printer, model, errorMode, true, paginationSpecs, serverUrl, dateType);
- return [{ path: pythonModulePath(outputPath), content: printer.toString() }];
+ const entry = { path: pythonModulePath(output.path), content: printer.toString() };
+ if (emit.runtime !== 'module') return [entry];
+ // The runtime modules, verbatim except the package-relative imports: the flat
+ // sibling layout has no package, so `from ._x` becomes the sibling `from _x`.
+ const header = banner.map((line) => `# ${line}`).join('\n');
+ const dir = pythonModulePath(output.path).replace(/[^\\/]+$/, '');
+ const runtimeFiles = Object.entries(PYTHON_RUNTIME_SOURCES).map(([name, source]) => ({
+ path: `${dir}${name}`,
+ content: `${header}\n\n${source
+ .split('\n')
+ .map((line) => (line.startsWith('from ._') ? line.replace('from ._', 'from _') : line))
+ .join('\n')
+ .trim()}\n`,
+ }));
+ return [entry, ...runtimeFiles];
};
/** One idiomatic Python call per operation — feeds `x-codeSamples` for docs. */
@@ -907,7 +184,11 @@ export function pythonSample(op: OperationModel, ctx: SampleContext): CodeSample
const module = pythonModulePath(ctx.outputPath)
.replace(/^.*[\\/]/, '')
.replace(/\.py$/, '');
- const ident = identifierFor(op.name, { style: 'snake', reserved: PY });
+ // The DEDUPED name: on a collision the method is `get_user_2`, and a snippet naming
+ // the raw `get_user` would show a call that goes to a different operation.
+ const ident =
+ operationIdents(ctx.model).find((entry) => entry.op.name === op.name)?.ident ??
+ identifierFor(op.name, { style: 'snake', reserved: PY });
const args = [
...op.pathParams.map((param) => {
const python = identifierFor(param.name, { style: 'snake', reserved: PY });
@@ -933,9 +214,9 @@ export function pythonSample(op: OperationModel, ctx: SampleContext): CodeSample
* from `pythonSample` — this generator's own hook — so the page can only ever show the syntax
* of the SDK beside it, and ejecting this generator takes the page with it.
*/
-export const pythonDocs: Generator = ({ model, outputPath, emit }) => [
+export const pythonDocs: Generator = ({ model, output, emit, pagination }) => [
{
- path: outputPath.replace(/\.[^.\\/]+$/, '.python.md'),
+ path: output.path.replace(/\.[^.\\/]+$/, '.python.md'),
content: renderReferencePage(model, {
title: `${model.title} Python SDK reference`,
frontmatter: emit.docsFrontmatter === true,
@@ -945,8 +226,8 @@ export const pythonDocs: Generator = ({ model, outputPath, emit }) => [
fence: 'python',
requires: 'The SDK needs `httpx`.',
},
- sample: (op) => pythonSample(op, { model, emit, outputPath }),
- pagination: emit.pagination,
+ sample: (op) => pythonSample(op, { model, emit, outputPath: output.path }),
+ paginated: new Set(pagination?.keys() ?? []),
}),
},
];
diff --git a/packages/client-generator/src/generators/python/models.ts b/packages/client-generator/src/generators/python/models.ts
new file mode 100644
index 0000000000..05e9128313
--- /dev/null
+++ b/packages/client-generator/src/generators/python/models.ts
@@ -0,0 +1,238 @@
+// The `models` stage: named schemas as Enum classes, dataclasses/pydantic models
+// (allOf flattened), union aliases, and the decoder's discriminator registrations.
+
+import {
+ type ApiModel,
+ type DateType,
+ discriminatorCases,
+ enumValues,
+ flattenAllOf,
+ type PropertyModel,
+} from '@redocly/client-generator';
+import { PythonPrinter } from '@redocly/client-generator/printers/python';
+
+import { className, fieldName, naming } from './naming.ts';
+import { pythonType } from './types.ts';
+
+/** The model style the generator emits: plain dataclasses, or pydantic `BaseModel`s. */
+export type PythonModels = 'dataclass' | 'pydantic';
+
+/** The wire property and value a union's discriminator mapping pins on one member class. */
+export type DiscriminatorPin = { property: string; value: string };
+
+/**
+ * Under `models: pydantic` the decoder hands a whole object tree to `model_validate`, so a
+ * union nested in a model is resolved by pydantic and never reaches the `DISCRIMINATORS`
+ * table. Pydantic resolves it correctly when the annotation carries the discriminator, which
+ * it accepts only if every member types that property as a `Literal` — and the mapping
+ * already pins one value per member. This pass works out which unions qualify: every member
+ * must declare the property, and no member may be pinned to two different values (a schema
+ * reused by two unions).
+ */
+export function pydanticDiscriminators(model: ApiModel): {
+ pins: Map;
+ unions: Map;
+} {
+ const pins = new Map();
+ const conflicted = new Set();
+ const candidates: Array<{ name: string; property: string; members: string[] }> = [];
+ for (const { name, schema } of model.schemas) {
+ const cases = discriminatorCases(schema, model);
+ if (cases === undefined) continue;
+ const declares = cases.cases.every(
+ (entry) =>
+ flattenAllOf(entry.schema, model)?.properties.some(
+ (property) => property.name === cases.property
+ ) === true
+ );
+ if (!declares) continue;
+ for (const entry of cases.cases) {
+ const existing = pins.get(entry.schemaName);
+ if (existing !== undefined && existing.value !== entry.value) {
+ conflicted.add(entry.schemaName);
+ continue;
+ }
+ pins.set(entry.schemaName, { property: cases.property, value: entry.value });
+ }
+ candidates.push({
+ name,
+ property: cases.property,
+ members: cases.cases.map((entry) => entry.schemaName),
+ });
+ }
+ const unions = new Map();
+ for (const candidate of candidates) {
+ if (candidate.members.some((member) => conflicted.has(member))) continue;
+ unions.set(candidate.name, fieldName(candidate.property).python);
+ }
+ for (const member of conflicted) pins.delete(member);
+ return { pins, unions };
+}
+
+function writeDataclass(
+ printer: PythonPrinter,
+ name: string,
+ properties: PropertyModel[],
+ dateType: DateType,
+ models: PythonModels,
+ description?: string,
+ /** The discriminator value this class is mapped to, pinned as a `Literal` (pydantic). */
+ pinned?: DiscriminatorPin
+): void {
+ const pydantic = models === 'pydantic';
+ if (!pydantic) printer.line('@dataclass');
+ const header = pydantic ? `class ${className(name)}(BaseModel):` : `class ${className(name)}:`;
+ printer.block(header, () => {
+ printer.doc(description);
+ // A wire name that is not a legal field name travels as an alias, so the model
+ // accepts both spellings; without this, populating by field name would fail.
+ if (pydantic) {
+ printer.line('model_config = ConfigDict(populate_by_name=True)');
+ printer.blank();
+ }
+ // Required fields first — a dataclass field without a default may not follow one with.
+ const ordered = [
+ ...properties.filter((property) => property.required),
+ ...properties.filter((property) => !property.required),
+ ];
+ const fieldMap: Array<[string, string]> = [];
+ if (ordered.length === 0) printer.line('pass');
+ for (const property of ordered) {
+ const { python, renamed } = fieldName(property.name);
+ if (renamed && !pydantic) fieldMap.push([python, property.name]);
+ const alias = renamed && pydantic ? `alias=${naming.string(property.name)}` : undefined;
+ const baseType =
+ pinned?.property === property.name
+ ? `Literal[${naming.literal(pinned.value)}]`
+ : pythonType(property.schema, dateType);
+ if (property.required) {
+ const value = alias === undefined ? '' : ` = Field(${alias})`;
+ printer.line(`${python}: ${baseType}${value}`);
+ } else {
+ const optional = baseType.startsWith('Optional[') ? baseType : `Optional[${baseType}]`;
+ const value = alias === undefined ? 'None' : `Field(default=None, ${alias})`;
+ printer.line(`${python}: ${optional} = ${value}`);
+ }
+ }
+ if (fieldMap.length > 0) {
+ printer.blank();
+ printer.line('# Python field name -> wire (JSON) name, for (de)serialization.');
+ const entries = fieldMap.map(([py, wire]) => `"${py}": ${naming.string(wire)}`).join(', ');
+ printer.line(`_field_map: ClassVar[Dict[str, str]] = {${entries}}`);
+ }
+ });
+ printer.blank();
+ printer.blank();
+}
+
+/** Render every named schema: Enum classes, dataclasses (allOf flattened), union aliases. */
+export function renderPythonModels(
+ model: ApiModel,
+ dateType: DateType = 'string',
+ models: PythonModels = 'dataclass'
+): string {
+ const printer = new PythonPrinter();
+ const { pins, unions } =
+ models === 'pydantic'
+ ? pydanticDiscriminators(model)
+ : { pins: new Map(), unions: new Map() };
+ printer.line('from __future__ import annotations');
+ printer.blank();
+ if (models === 'dataclass') printer.line('from dataclasses import dataclass');
+ printer.line('from enum import Enum');
+ // `ClassVar` types the `_field_map` of a dataclass model, which pydantic mode
+ // replaces with field aliases — importing it there would be an unused import.
+ const typingNames = [
+ 'Any',
+ 'AsyncIterator',
+ 'Dict',
+ 'Iterator',
+ 'List',
+ 'Literal',
+ 'Optional',
+ 'Tuple',
+ 'Union',
+ ];
+ if (models === 'dataclass') typingNames.splice(2, 0, 'ClassVar');
+ if (unions.size > 0) typingNames.unshift('Annotated');
+ printer.line(`from typing import ${typingNames.join(', ')}`);
+ if (models === 'pydantic') printer.line('from pydantic import BaseModel, ConfigDict, Field');
+ // Only under `dateType: Date` — an unused import in every other client would be noise.
+ if (dateType === 'Date') printer.line('from datetime import date, datetime');
+ printer.blank();
+ printer.blank();
+
+ const aliases: Array<() => void> = [];
+ for (const { name, schema } of model.schemas) {
+ const asEnum = enumValues(schema);
+ if (asEnum !== undefined) {
+ const base = asEnum.scalar === 'string' ? 'str, Enum' : 'int, Enum';
+ printer.block(`class ${className(name)}(${base}):`, () => {
+ printer.doc(schema.description);
+ asEnum.values.forEach((value, index) => {
+ printer.line(`${asEnum.memberNames[index]} = ${naming.literal(value)}`);
+ });
+ });
+ printer.blank();
+ printer.blank();
+ continue;
+ }
+ if (schema.kind === 'object' || schema.kind === 'intersection') {
+ const flat = flattenAllOf(schema, model);
+ if (flat !== undefined) {
+ writeDataclass(
+ printer,
+ name,
+ flat.properties,
+ dateType,
+ models,
+ flat.description ?? schema.description,
+ pins.get(name)
+ );
+ continue;
+ }
+ }
+ // Everything else (unions, scalar aliases, records) becomes a module-level alias,
+ // emitted AFTER the classes it references so the assignment evaluates.
+ aliases.push(() => {
+ const cases = discriminatorCases(schema, model);
+ if (cases !== undefined) {
+ const table = cases.cases
+ .map((entry) => `${entry.value} -> ${className(entry.schemaName)}`)
+ .join(', ');
+ printer.line(`# Discriminated by "${cases.property}": ${table}`);
+ }
+ const field = unions.get(name);
+ const union =
+ field === undefined
+ ? pythonType(schema, dateType)
+ : `Annotated[${pythonType(schema, dateType)}, Field(discriminator=${naming.string(field)})]`;
+ printer.line(`${className(name)} = ${union}`);
+ printer.blank();
+ });
+ }
+ for (const emit of aliases) emit();
+ return printer.toString();
+}
+
+/**
+ * `DISCRIMINATORS[Pet] = ("petType", {"cat": Cat, ...})` registration lines, which `decode`
+ * dispatches through. A union whose annotation already carries the discriminator is left
+ * out: pydantic resolves it at any depth, and the `Literal` on each member makes the
+ * decoder's member probe exact.
+ */
+export function discriminatorRegistrations(model: ApiModel, annotated: Set): string[] {
+ const lines: string[] = [];
+ for (const { name, schema } of model.schemas) {
+ if (annotated.has(name)) continue;
+ const cases = discriminatorCases(schema, model);
+ if (cases === undefined) continue;
+ const mapping = cases.cases
+ .map((entry) => `${naming.string(entry.value)}: ${className(entry.schemaName)}`)
+ .join(', ');
+ lines.push(
+ `DISCRIMINATORS[${className(name)}] = (${naming.string(cases.property)}, {${mapping}})`
+ );
+ }
+ return lines;
+}
diff --git a/packages/client-generator/src/generators/python/naming.ts b/packages/client-generator/src/generators/python/naming.ts
new file mode 100644
index 0000000000..14644b3e27
--- /dev/null
+++ b/packages/client-generator/src/generators/python/naming.ts
@@ -0,0 +1,42 @@
+// The `naming` stage: the shared printer/naming instance and the collision-free
+// identifier derivations every other stage builds on.
+
+import {
+ type ApiModel,
+ type OperationModel,
+ RESERVED_WORDS,
+ uniqueIdentifiers,
+} from '@redocly/client-generator';
+import { PythonPrinter } from '@redocly/client-generator/printers/python';
+
+export const PY = RESERVED_WORDS.python;
+
+// Naming delegates to the printer — one implementation, used here and by any ejected copy.
+export const naming = new PythonPrinter();
+
+/** A named schema's Python class name. */
+export function className(name: string): string {
+ return naming.typeName(name);
+}
+
+/** A field/parameter name, with the wire name preserved when sanitization renames it. */
+export function fieldName(name: string): { python: string; renamed: boolean } {
+ const { identifier, renamed } = naming.memberName(name);
+ return { python: identifier, renamed };
+}
+
+/** Every operation with its collision-free snake_case Python method name. */
+export function operationIdents(model: ApiModel): Array<{ op: OperationModel; ident: string }> {
+ const operations = model.services.flatMap((service) => service.operations);
+ const idents = uniqueIdentifiers(
+ operations.map((op) => op.name),
+ { style: 'snake', reserved: PY }
+ );
+ return operations.map((op, index) => ({ op, ident: idents[index] }));
+}
+
+/**
+ * The argument names every request method declares itself. A parameter named after one of
+ * them takes a suffixed binding instead, so the slot keeps its meaning.
+ */
+export const METHOD_ARG_SLOTS = ['self', 'body', 'headers', 'timeout', 'retry', 'idempotency_key'];
diff --git a/packages/client-generator/src/generators/python/operations.ts b/packages/client-generator/src/generators/python/operations.ts
new file mode 100644
index 0000000000..8837770f10
--- /dev/null
+++ b/packages/client-generator/src/generators/python/operations.ts
@@ -0,0 +1,143 @@
+// The `operations` stage: one typed request method per operation (sync and async),
+// with the optional `_with_headers` envelope variant.
+
+import {
+ type ApiModel,
+ type DateType,
+ isMultipartBody,
+ jsonSuccessSchema,
+ type OperationModel,
+ sseResponse,
+ uniqueIdentifiers,
+} from '@redocly/client-generator';
+import type { PythonPrinter } from '@redocly/client-generator/printers/python';
+
+import { envelopeHeaderSpecs } from './descriptor.ts';
+import { METHOD_ARG_SLOTS, naming, PY } from './naming.ts';
+import { pythonType } from './types.ts';
+
+export function writeMethod(
+ printer: PythonPrinter,
+ op: OperationModel,
+ ident: string,
+ errorMode: 'throw' | 'result',
+ isAsync: boolean,
+ dateType: DateType,
+ model?: ApiModel,
+ envelope = false
+): void {
+ // Every parameter is a separate argument, so path and query names share one namespace
+ // with the slots this method declares itself. `uniqueIdentifiers` moves a repeat aside
+ // (`id`, `id_2`) — a description may legally use one name in two locations, and a
+ // signature that declared it twice would not even parse.
+ const argNames = uniqueIdentifiers(
+ [...op.pathParams, ...op.queryParams].map((param) => param.name),
+ { style: 'snake', reserved: PY, taken: METHOD_ARG_SLOTS }
+ );
+ const pathArgs = op.pathParams.map((param, index) => ({ param, python: argNames[index] }));
+ const queryArgs = op.queryParams.map((param, index) => ({
+ param,
+ python: argNames[op.pathParams.length + index],
+ }));
+ const positional = pathArgs.map(
+ ({ param, python }) => `${python}: ${pythonType(param.schema, dateType)}`
+ );
+ const bodyArg = op.requestBody ? [`body: ${pythonType(op.requestBody.schema, dateType)}`] : [];
+ const kwargs = [
+ ...queryArgs.map(({ param, python }) => {
+ const annotation = pythonType(param.schema, dateType);
+ const optional = annotation.startsWith('Optional[') ? annotation : `Optional[${annotation}]`;
+ return `${python}: ${optional} = None`;
+ }),
+ 'headers: Optional[Dict[str, str]] = None',
+ 'timeout: Optional[float] = None',
+ 'retry: Optional[Dict[str, Any]] = None',
+ 'idempotency_key: Any = None',
+ ];
+ const success = jsonSuccessSchema(op);
+ const sse = sseResponse(op);
+ const returns = envelope
+ ? `Envelope[${success === undefined ? 'None' : pythonType(success, dateType)}]`
+ : sse !== undefined
+ ? `${isAsync ? 'AsyncIterator' : 'Iterator'}[ServerSentEvent]`
+ : errorMode === 'result'
+ ? 'Result'
+ : success === undefined
+ ? 'None'
+ : pythonType(success, dateType);
+ // Streaming methods are plain defs returning an (async) iterator — an `async def`
+ // would force awaiting the call before iterating it.
+ const prefix = isAsync && sse === undefined ? 'async def' : 'def';
+ const awaitKw = isAsync ? 'await ' : '';
+ const sendFn = isAsync ? 'send_async' : 'send';
+ const signature = ['self', ...positional, ...bodyArg, '*', ...kwargs].join(', ');
+ const defName = envelope ? `${ident}_with_headers` : ident;
+ printer.block(`${prefix} ${defName}(${signature}) -> ${returns}:`, () => {
+ printer.doc(
+ envelope
+ ? `Like ${ident}(), returning an Envelope with the declared response headers.`
+ : op.summary
+ );
+ printer.line(`op = _OPERATIONS["${ident}"]`);
+ printer.line('auth_headers, auth_query = resolve_auth(op.get("security") or [], self._auth)');
+ printer.line('params: Dict[str, Any] = dict(auth_query)');
+ for (const { param, python } of queryArgs) {
+ printer.block(`if ${python} is not None:`, () => {
+ printer.line(`params[${naming.string(param.name)}] = encode(${python})`);
+ });
+ }
+ const pathDict = pathArgs
+ .map(({ param, python }) => `${naming.string(param.name)}: ${python}`)
+ .join(', ');
+ printer.line(`url = build_url(self._server_url, op["path"], {${pathDict}})`);
+ if (sse !== undefined) {
+ const dataKind = sse.schema !== undefined && sse.schema.kind !== 'unknown' ? 'json' : 'text';
+ printer.block('def _open(extra_headers: Dict[str, str]):', () => {
+ printer.line(
+ 'return self._http.stream(op["method"], url, ' +
+ 'headers={**auth_headers, **(headers or {}), **extra_headers}, params=params, timeout=timeout)'
+ );
+ });
+ printer.line(`return ${isAsync ? 'aiter_sse' : 'iter_sse'}(_open, data_kind="${dataKind}")`);
+ return;
+ }
+ if (isMultipartBody(op)) printer.line('form_data, form_files = to_multipart(body)');
+ const bodyKw = op.requestBody
+ ? isMultipartBody(op)
+ ? ', data=form_data, files=form_files'
+ : ', json_body=encode(body)'
+ : '';
+ printer.line(
+ `response = ${awaitKw}${sendFn}(self._http, self._config, op, url, method=op["method"], ` +
+ `headers={**auth_headers, **(headers or {})}, params=params${bodyKw}, ` +
+ 'timeout=timeout, retry=retry, idempotency_key=idempotency_key)'
+ );
+ const decoded =
+ success === undefined
+ ? 'None'
+ : `decode(${pythonType(success, dateType)}, _safe_json(response))`;
+ if (envelope) {
+ printer.block('if not response.is_success:', () => {
+ printer.line(
+ 'raise ApiError(url, response.status_code, response.reason_phrase, _safe_json(response))'
+ );
+ });
+ printer.line(
+ `return Envelope(data=${decoded}, headers=read_envelope_headers(response, ${envelopeHeaderSpecs(op, model!)}), response=response)`
+ );
+ } else if (errorMode === 'result') {
+ printer.block('if not response.is_success:', () => {
+ printer.line('return Result(data=None, error=_safe_json(response), response=response)');
+ });
+ printer.line(`return Result(data=${decoded}, error=None, response=response)`);
+ } else {
+ printer.block('if not response.is_success:', () => {
+ printer.line(
+ 'raise ApiError(url, response.status_code, response.reason_phrase, _safe_json(response))'
+ );
+ });
+ printer.line(success === undefined ? 'return None' : `return ${decoded}`);
+ }
+ });
+ printer.blank();
+}
diff --git a/packages/client-generator/src/generators/python/pagination.ts b/packages/client-generator/src/generators/python/pagination.ts
new file mode 100644
index 0000000000..2d15e2d871
--- /dev/null
+++ b/packages/client-generator/src/generators/python/pagination.ts
@@ -0,0 +1,124 @@
+// The `pagination` stage: `_pages` / `_items` iterator methods for
+// paginated operations, sync and async.
+
+import {
+ type DateType,
+ jsonSuccessSchema,
+ type OperationModel,
+ uniqueIdentifiers,
+} from '@redocly/client-generator';
+import type { PythonPrinter } from '@redocly/client-generator/printers/python';
+
+import { METHOD_ARG_SLOTS, naming, PY } from './naming.ts';
+import { pythonType } from './types.ts';
+
+/** `_pages` / `_items` iterator methods for a paginated operation. */
+export function writePaginationWrappers(
+ printer: PythonPrinter,
+ op: OperationModel,
+ ident: string,
+ isAsync: boolean,
+ itemType: string,
+ dateType: DateType
+): void {
+ const success = jsonSuccessSchema(op);
+ const pageType = success === undefined ? 'Any' : pythonType(success, dateType);
+ // The iterators take the same arguments as the operation itself, computed the same way,
+ // so a name the method moved aside (`id_2`) is the same name here — copying a call from
+ // one to the other has to keep working. Path values are substituted, not dropped.
+ const argNames = uniqueIdentifiers(
+ [...op.pathParams, ...op.queryParams].map((param) => param.name),
+ { style: 'snake', reserved: PY, taken: METHOD_ARG_SLOTS }
+ );
+ const pathArgs = op.pathParams.map((param, index) => ({ param, python: argNames[index] }));
+ const queryArgs = op.queryParams.map((param, index) => ({
+ param,
+ python: argNames[op.pathParams.length + index],
+ }));
+ const positional = pathArgs.map(
+ ({ param, python }) => `${python}: ${pythonType(param.schema, dateType)}`
+ );
+ const kwargs = [
+ ...queryArgs.map(({ param, python }) => {
+ const annotation = pythonType(param.schema, dateType);
+ const optional = annotation.startsWith('Optional[') ? annotation : `Optional[${annotation}]`;
+ return `${python}: ${optional} = None`;
+ }),
+ 'headers: Optional[Dict[str, str]] = None',
+ 'timeout: Optional[float] = None',
+ 'retry: Optional[Dict[str, Any]] = None',
+ ];
+ const signature = ['self', ...positional, '*', ...kwargs].join(', ');
+ const iterType = isAsync ? 'AsyncIterator' : 'Iterator';
+ const pagesFn = isAsync ? 'aiter_pages' : 'iter_pages';
+ const itemsFn = isAsync ? 'aiter_items' : 'iter_items';
+
+ const writeCallClosure = () => {
+ printer.line('base: Dict[str, Any] = {}');
+ for (const { param, python } of queryArgs) {
+ printer.block(`if ${python} is not None:`, () => {
+ printer.line(`base[${naming.string(param.name)}] = encode(${python})`);
+ });
+ }
+ const prefix = isAsync ? 'async def' : 'def';
+ const awaitKw = isAsync ? 'await ' : '';
+ printer.block(`${prefix} _page(page_params: Dict[str, Any]) -> Tuple[Any, Any]:`, () => {
+ printer.line('auth_headers, auth_query = resolve_auth(op.get("security") or [], self._auth)');
+ const pathDict = pathArgs
+ .map(({ param, python }) => `${naming.string(param.name)}: ${python}`)
+ .join(', ');
+ printer.line(`url = build_url(self._server_url, op["path"], {${pathDict}})`);
+ printer.line(
+ `response = ${awaitKw}${isAsync ? 'send_async' : 'send'}(self._http, self._config, op, url, method=op["method"], ` +
+ 'headers={**auth_headers, **(headers or {})}, params={**page_params, **auth_query}, ' +
+ 'timeout=timeout, retry=retry)'
+ );
+ printer.block('if not response.is_success:', () => {
+ printer.line(
+ 'raise ApiError(url, response.status_code, response.reason_phrase, _safe_json(response))'
+ );
+ });
+ printer.line('return _safe_json(response), response');
+ });
+ };
+
+ // pages: raw page JSON decoded into the page model per page.
+ if (isAsync) {
+ printer.block(`async def ${ident}_pages(${signature}) -> ${iterType}[${pageType}]:`, () => {
+ printer.line(`op = _OPERATIONS["${ident}"]`);
+ writeCallClosure();
+ printer.block(`async for page in ${pagesFn}(_page, op["pagination"], base):`, () => {
+ printer.line(pageType === 'Any' ? 'yield page' : `yield decode(${pageType}, page)`);
+ });
+ });
+ printer.blank();
+ printer.block(`async def ${ident}_items(${signature}) -> ${iterType}[${itemType}]:`, () => {
+ printer.line(`op = _OPERATIONS["${ident}"]`);
+ writeCallClosure();
+ printer.block(`async for item in ${itemsFn}(_page, op["pagination"], base):`, () => {
+ printer.line(itemType === 'Any' ? 'yield item' : `yield decode(${itemType}, item)`);
+ });
+ });
+ } else {
+ printer.block(`def ${ident}_pages(${signature}) -> ${iterType}[${pageType}]:`, () => {
+ printer.line(`op = _OPERATIONS["${ident}"]`);
+ writeCallClosure();
+ printer.line(
+ pageType === 'Any'
+ ? `return ${pagesFn}(_page, op["pagination"], base)`
+ : `return (decode(${pageType}, page) for page in ${pagesFn}(_page, op["pagination"], base))`
+ );
+ });
+ printer.blank();
+ printer.block(`def ${ident}_items(${signature}) -> ${iterType}[${itemType}]:`, () => {
+ printer.line(`op = _OPERATIONS["${ident}"]`);
+ writeCallClosure();
+ printer.line(
+ itemType === 'Any'
+ ? `return ${itemsFn}(_page, op["pagination"], base)`
+ : `return (decode(${itemType}, item) for item in ${itemsFn}(_page, op["pagination"], base))`
+ );
+ });
+ }
+ printer.blank();
+}
diff --git a/packages/client-generator/runtime/python/_auth.py b/packages/client-generator/src/generators/python/runtime/_auth.py
similarity index 100%
rename from packages/client-generator/runtime/python/_auth.py
rename to packages/client-generator/src/generators/python/runtime/_auth.py
diff --git a/packages/client-generator/runtime/python/_decode.py b/packages/client-generator/src/generators/python/runtime/_decode.py
similarity index 100%
rename from packages/client-generator/runtime/python/_decode.py
rename to packages/client-generator/src/generators/python/runtime/_decode.py
diff --git a/packages/client-generator/runtime/python/_errors.py b/packages/client-generator/src/generators/python/runtime/_errors.py
similarity index 100%
rename from packages/client-generator/runtime/python/_errors.py
rename to packages/client-generator/src/generators/python/runtime/_errors.py
diff --git a/packages/client-generator/runtime/python/_multipart.py b/packages/client-generator/src/generators/python/runtime/_multipart.py
similarity index 100%
rename from packages/client-generator/runtime/python/_multipart.py
rename to packages/client-generator/src/generators/python/runtime/_multipart.py
diff --git a/packages/client-generator/runtime/python/_paginate.py b/packages/client-generator/src/generators/python/runtime/_paginate.py
similarity index 100%
rename from packages/client-generator/runtime/python/_paginate.py
rename to packages/client-generator/src/generators/python/runtime/_paginate.py
diff --git a/packages/client-generator/runtime/python/_send.py b/packages/client-generator/src/generators/python/runtime/_send.py
similarity index 100%
rename from packages/client-generator/runtime/python/_send.py
rename to packages/client-generator/src/generators/python/runtime/_send.py
diff --git a/packages/client-generator/runtime/python/_sse.py b/packages/client-generator/src/generators/python/runtime/_sse.py
similarity index 100%
rename from packages/client-generator/runtime/python/_sse.py
rename to packages/client-generator/src/generators/python/runtime/_sse.py
diff --git a/packages/client-generator/runtime/python/_url.py b/packages/client-generator/src/generators/python/runtime/_url.py
similarity index 100%
rename from packages/client-generator/runtime/python/_url.py
rename to packages/client-generator/src/generators/python/runtime/_url.py
diff --git a/packages/client-generator/src/generators/python/types.ts b/packages/client-generator/src/generators/python/types.ts
new file mode 100644
index 0000000000..6c03f1a9c3
--- /dev/null
+++ b/packages/client-generator/src/generators/python/types.ts
@@ -0,0 +1,50 @@
+// The `types` stage: schema → Python type annotation.
+
+import {
+ type DateType,
+ isNullable,
+ type SchemaModel,
+ unwrapNullable,
+} from '@redocly/client-generator';
+
+import { className, naming } from './naming.ts';
+
+/** The Python type annotation for a schema (anonymous complex shapes collapse to Any-ish). */
+export function pythonType(schema: SchemaModel, dateType: DateType = 'string'): string {
+ if (isNullable(schema)) {
+ return `Optional[${pythonType(unwrapNullable(schema), dateType)}]`;
+ }
+ switch (schema.kind) {
+ case 'scalar':
+ // `dateType: Date` annotates date/date-time as stdlib objects; `_decode.py`
+ // converts them from and to ISO strings on the wire.
+ if (dateType === 'Date' && schema.scalar === 'string') {
+ if (schema.metadata?.format === 'date-time') return 'datetime';
+ if (schema.metadata?.format === 'date') return 'date';
+ }
+ return { string: 'str', integer: 'int', number: 'float', boolean: 'bool' }[schema.scalar];
+ case 'array':
+ return `List[${pythonType(schema.items, dateType)}]`;
+ case 'record':
+ return `Dict[str, ${pythonType(schema.value, dateType)}]`;
+ case 'ref':
+ return className(schema.name);
+ case 'literal':
+ return `Literal[${naming.literal(schema.value)}]`;
+ case 'enum':
+ // Anonymous (inline) enums keep the wire scalar; only NAMED enums get classes.
+ return { string: 'str', integer: 'int', number: 'float', boolean: 'bool' }[schema.scalar];
+ case 'union':
+ return `Union[${schema.members.map((member) => pythonType(member, dateType)).join(', ')}]`;
+ case 'null':
+ return 'None';
+ case 'omit':
+ // Python has no Omit; the base class is the honest annotation (readOnly
+ // fields are server-managed and simply absent on requests).
+ return className(schema.base);
+ case 'object':
+ case 'intersection':
+ case 'unknown':
+ return 'Any';
+ }
+}
diff --git a/packages/client-generator/src/generators/resolve.ts b/packages/client-generator/src/generators/resolve.ts
index 36161b139e..5dca7456fb 100644
--- a/packages/client-generator/src/generators/resolve.ts
+++ b/packages/client-generator/src/generators/resolve.ts
@@ -142,11 +142,15 @@ function register(registry: Map, custom: CustomGene
registry.set(custom.name, {
run: custom.run,
sample: custom.sample,
+ // `docs` and `notApplicable` are part of the contract the ejected files export —
+ // dropping either makes an ejected generator quietly do less than the built-in it
+ // replaced (`--docs` writes no page, ignored options stop warning).
+ docs: custom.docs,
+ notApplicable: custom.notApplicable,
options: custom.options,
requires: custom.requires,
errorModes: custom.errorModes,
dateTypes: custom.dateTypes,
- runtimes: custom.runtimes,
});
}
@@ -161,6 +165,13 @@ async function importGenerator(specifier: string, configDir: string): Promise;
try {
module = (await import(target)) as Record;
diff --git a/packages/client-generator/src/emitters/__tests__/swr.test.ts b/packages/client-generator/src/generators/swr/__tests__/render.test.ts
similarity index 98%
rename from packages/client-generator/src/emitters/__tests__/swr.test.ts
rename to packages/client-generator/src/generators/swr/__tests__/render.test.ts
index 7c9fac8770..a32b8e8163 100644
--- a/packages/client-generator/src/emitters/__tests__/swr.test.ts
+++ b/packages/client-generator/src/generators/swr/__tests__/render.test.ts
@@ -1,5 +1,5 @@
-import { renderSwrModule } from '../swr.js';
-import { apiModel, namedSchema, operation, param, SCALAR } from './fixtures.js';
+import { apiModel, namedSchema, operation, param, SCALAR } from '../../../__tests__/fixtures.js';
+import { renderSwrModule } from '../render.js';
const SDK = './client.js';
diff --git a/packages/client-generator/src/generators/swr/index.ts b/packages/client-generator/src/generators/swr/index.ts
index e6443c6d52..7627c68021 100644
--- a/packages/client-generator/src/generators/swr/index.ts
+++ b/packages/client-generator/src/generators/swr/index.ts
@@ -1,9 +1,7 @@
+import type { Generator } from '@redocly/client-generator';
import { join } from 'node:path';
-import { HEADER } from '../../emitters/emit-options.js';
-import { renderSwrModule } from '../../emitters/swr.js';
-import { anchor } from '../anchor.js';
-import type { Generator } from '../types.js';
+import { renderSwrModule } from './render.ts';
/**
* The swr generator: a standalone `.swr.ts` module of SWR hooks wrapping the
@@ -17,11 +15,11 @@ import type { Generator } from '../types.js';
* multi-file barrel at the output anchor either way. Emits nothing when there are
* no operations.
*/
-export const swrGenerator: Generator = ({ model, outputPath, emit }) => {
- const { dir, stem } = anchor(outputPath);
+export const swrGenerator: Generator = ({ model, output, banner, emit }) => {
const content = renderSwrModule(model, {
- sdkModule: `./${stem}.${emit.importExt ?? 'js'}`,
+ sdkModule: `./${output.stem}.${emit.importExt ?? 'js'}`,
});
if (content === '') return [];
- return [{ path: join(dir, `${stem}.swr.ts`), content: `${HEADER}\n\n${content}` }];
+ const header = banner.map((line) => `// ${line}`).join('\n');
+ return [{ path: join(output.dir, `${output.stem}.swr.ts`), content: `${header}\n\n${content}` }];
};
diff --git a/packages/client-generator/src/emitters/swr.ts b/packages/client-generator/src/generators/swr/render.ts
similarity index 94%
rename from packages/client-generator/src/emitters/swr.ts
rename to packages/client-generator/src/generators/swr/render.ts
index 4a218db0fc..4d3b746d8c 100644
--- a/packages/client-generator/src/emitters/swr.ts
+++ b/packages/client-generator/src/generators/swr/render.ts
@@ -8,8 +8,7 @@
// `swr`/`swr/mutation` are the consumer's peer; the sdk stays dependency-free.
// Source-text templates throughout.
-import type { ApiModel, OperationModel } from '../intermediate-representation/model.js';
-import { pascalCase } from './support.js';
+import type { ApiModel, OperationModel } from '@redocly/client-generator';
import {
hasInputs,
isQuery,
@@ -17,7 +16,8 @@ import {
sdkNamedImportText,
variablesName,
wrappableOperations,
-} from './wrapper-support.js';
+} from '@redocly/client-generator/contracts/typescript';
+import { pascalCase } from '@redocly/client-generator/printers/typescript';
export type SwrOptions = {
/** Import specifier for the sdk entry the operation functions/types live in. */
diff --git a/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts b/packages/client-generator/src/generators/tanstack-query/__tests__/render.test.ts
similarity index 94%
rename from packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts
rename to packages/client-generator/src/generators/tanstack-query/__tests__/render.test.ts
index 2233a36ef8..ee51bbaae8 100644
--- a/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts
+++ b/packages/client-generator/src/generators/tanstack-query/__tests__/render.test.ts
@@ -1,6 +1,6 @@
-import type { PaginationConfig } from '../pagination.js';
-import { renderTanstackModule } from '../tanstack-query.js';
-import { apiModel, namedSchema, operation, param, SCALAR } from './fixtures.js';
+import { apiModel, namedSchema, operation, param, SCALAR } from '../../../__tests__/fixtures.js';
+import { resolveModelPagination, type PaginationConfig } from '../../../pagination.js';
+import { renderTanstackModule } from '../render.js';
const SDK = './client.js';
@@ -12,13 +12,15 @@ function render(
schemas?: NonNullable[0]>['schemas'];
} = {}
) {
- return renderTanstackModule(
- apiModel({
- schemas: extra.schemas ?? [],
- services: [{ name: 'Default', operations: ops.map(operation) }],
- }),
- { sdkModule: SDK, framework: extra.framework ?? 'react', pagination: extra.pagination }
- );
+ const model = apiModel({
+ schemas: extra.schemas ?? [],
+ services: [{ name: 'Default', operations: ops.map(operation) }],
+ });
+ return renderTanstackModule(model, {
+ sdkModule: SDK,
+ framework: extra.framework ?? 'react',
+ pagination: resolveModelPagination(model, extra.pagination),
+ });
}
describe('renderTanstackModule', () => {
@@ -442,16 +444,21 @@ describe('a pagination parameter whose name is not an identifier', () => {
};
it('reads it with bracket access in both argument styles', () => {
- const grouped = renderTanstackModule(
- apiModel({ services: [{ name: 'Default', operations: [operation(spec)] }] }),
- { sdkModule: SDK, framework: 'react', pagination }
- );
+ const model = apiModel({ services: [{ name: 'Default', operations: [operation(spec)] }] });
+ const resolved = resolveModelPagination(model, pagination);
+ const grouped = renderTanstackModule(model, {
+ sdkModule: SDK,
+ framework: 'react',
+ pagination: resolved,
+ });
expect(grouped).toContain('initialPageParam: vars.query?.["after-cursor"]');
- const flat = renderTanstackModule(
- apiModel({ services: [{ name: 'Default', operations: [operation(spec)] }] }),
- { sdkModule: SDK, framework: 'react', pagination, argsStyle: 'flat' }
- );
+ const flat = renderTanstackModule(model, {
+ sdkModule: SDK,
+ framework: 'react',
+ pagination: resolved,
+ argsStyle: 'flat',
+ });
// `vars.["after-cursor"]` would not even parse.
expect(flat).toContain('initialPageParam: vars["after-cursor"]');
expect(flat).not.toContain('vars.[');
diff --git a/packages/client-generator/src/generators/tanstack-query/index.ts b/packages/client-generator/src/generators/tanstack-query/index.ts
index 9a455da762..245555623d 100644
--- a/packages/client-generator/src/generators/tanstack-query/index.ts
+++ b/packages/client-generator/src/generators/tanstack-query/index.ts
@@ -1,9 +1,7 @@
+import type { Generator } from '@redocly/client-generator';
import { join } from 'node:path';
-import { HEADER } from '../../emitters/emit-options.js';
-import { renderTanstackModule } from '../../emitters/tanstack-query.js';
-import { anchor } from '../anchor.js';
-import type { Generator } from '../types.js';
+import { renderTanstackModule } from './render.ts';
/**
* The tanstack-query generator: a standalone `.tanstack.ts` module of
@@ -21,16 +19,18 @@ import type { Generator } from '../types.js';
* no operations.
*/
export function tanstackQueryGenerator(framework: 'react' | 'vue' | 'svelte' | 'solid'): Generator {
- return ({ model, outputPath, emit }) => {
- const { dir, stem } = anchor(outputPath);
+ return ({ model, output, banner, emit, pagination }) => {
const content = renderTanstackModule(model, {
argsStyle: emit.argsStyle ?? 'grouped',
- sdkModule: `./${stem}.${emit.importExt ?? 'js'}`,
+ sdkModule: `./${output.stem}.${emit.importExt ?? 'js'}`,
framework,
- pagination: emit.pagination,
+ pagination,
queryKeyPrefix: emit.queryKeyPrefix,
});
if (content === '') return [];
- return [{ path: join(dir, `${stem}.tanstack.ts`), content: `${HEADER}\n\n${content}` }];
+ const header = banner.map((line) => `// ${line}`).join('\n');
+ return [
+ { path: join(output.dir, `${output.stem}.tanstack.ts`), content: `${header}\n\n${content}` },
+ ];
};
}
diff --git a/packages/client-generator/src/emitters/tanstack-query.ts b/packages/client-generator/src/generators/tanstack-query/render.ts
similarity index 96%
rename from packages/client-generator/src/emitters/tanstack-query.ts
rename to packages/client-generator/src/generators/tanstack-query/render.ts
index 4e0a43fc06..d9218dbfd9 100644
--- a/packages/client-generator/src/emitters/tanstack-query.ts
+++ b/packages/client-generator/src/generators/tanstack-query/render.ts
@@ -14,24 +14,32 @@
// is generator-derived (sanitized operation names, JSON-pointer property chains built
// here) — never raw spec text.
-import type { ApiModel, OperationModel } from '../intermediate-representation/model.js';
-import type { PaginationSpec } from '../runtime/types.js';
-import { codeString, isSafeIdentifier, safeIdent } from './identifier.js';
import {
+ type ApiModel,
type ModelPagination,
- type PaginationConfig,
- resolveModelPagination,
+ type OperationModel,
+ type PaginationSpec,
resolveSchemaPointer,
-} from './pagination.js';
-import { hasInputs, isQuery, variablesName, wrappableOperations } from './wrapper-support.js';
+} from '@redocly/client-generator';
+import {
+ hasInputs,
+ isQuery,
+ variablesName,
+ wrappableOperations,
+} from '@redocly/client-generator/contracts/typescript';
+import {
+ codeString,
+ isSafeIdentifier,
+ safeIdent,
+} from '@redocly/client-generator/printers/typescript';
export type TanstackOptions = {
/** Import specifier for the sdk entry the `client` instance and types live in. */
sdkModule: string;
/** TanStack adapter to import the option helpers from (`@tanstack/${framework}-query`). */
framework: 'react' | 'vue' | 'svelte' | 'solid';
- /** Auto-pagination rules — paginated query ops gain `InfiniteOptions`. */
- pagination?: PaginationConfig;
+ /** The run's RESOLVED pagination — paginated query ops gain `InfiniteOptions`. */
+ pagination?: ModelPagination;
/** Leading element for every query/mutation key — namespaces the cache when several
* generated APIs share one QueryClient (operationIds may collide across APIs). */
queryKeyPrefix?: string;
@@ -43,7 +51,7 @@ export type TanstackOptions = {
export function renderTanstackModule(model: ApiModel, opts: TanstackOptions): string {
const ops = wrappableOperations(model, 'tanstack-query');
if (ops.length === 0) return '';
- const pagination = resolveModelPagination(model, opts.pagination);
+ const pagination = opts.pagination ?? new Map();
const source = [
importHeader(ops, opts, pagination),
...ops.filter(isQuery).map((op) => queryKeySource(op, opts.queryKeyPrefix)),
diff --git a/packages/client-generator/src/emitters/__tests__/transformers.test.ts b/packages/client-generator/src/generators/transformers/__tests__/render.test.ts
similarity index 99%
rename from packages/client-generator/src/emitters/__tests__/transformers.test.ts
rename to packages/client-generator/src/generators/transformers/__tests__/render.test.ts
index aa73d24880..57786f80ca 100644
--- a/packages/client-generator/src/emitters/__tests__/transformers.test.ts
+++ b/packages/client-generator/src/generators/transformers/__tests__/render.test.ts
@@ -2,8 +2,8 @@ import type {
ApiModel,
NamedSchemaModel,
PropertyModel,
-} from '../../intermediate-representation/model.js';
-import { renderTransformersModule } from '../transformers.js';
+} from '../../../intermediate-representation/model.js';
+import { renderTransformersModule } from '../render.js';
const base: Omit = {
title: 'T',
diff --git a/packages/client-generator/src/generators/transformers/index.ts b/packages/client-generator/src/generators/transformers/index.ts
index cb71f963d3..81348cd405 100644
--- a/packages/client-generator/src/generators/transformers/index.ts
+++ b/packages/client-generator/src/generators/transformers/index.ts
@@ -1,9 +1,7 @@
+import type { Generator } from '@redocly/client-generator';
import { join } from 'node:path';
-import { HEADER } from '../../emitters/emit-options.js';
-import { renderTransformersModule } from '../../emitters/transformers.js';
-import { anchor } from '../anchor.js';
-import type { Generator } from '../types.js';
+import { renderTransformersModule } from './render.ts';
/**
* The transformers generator: a standalone `.transformers.ts` module of
@@ -21,11 +19,16 @@ import type { Generator } from '../types.js';
* beside the client regardless of how the sdk partitions its files. Emits
* nothing when no schema has a date field (nothing to transform).
*/
-export const transformersGenerator: Generator = ({ model, outputPath, emit }) => {
- const { dir, stem } = anchor(outputPath);
+export const transformersGenerator: Generator = ({ model, output, banner, emit }) => {
const content = renderTransformersModule(model, {
- sdkModule: `./${stem}.${emit.importExt ?? 'js'}`,
+ sdkModule: `./${output.stem}.${emit.importExt ?? 'js'}`,
});
if (content === '') return [];
- return [{ path: join(dir, `${stem}.transformers.ts`), content: `${HEADER}\n\n${content}` }];
+ const header = banner.map((line) => `// ${line}`).join('\n');
+ return [
+ {
+ path: join(output.dir, `${output.stem}.transformers.ts`),
+ content: `${header}\n\n${content}`,
+ },
+ ];
};
diff --git a/packages/client-generator/src/emitters/transformers.ts b/packages/client-generator/src/generators/transformers/render.ts
similarity index 98%
rename from packages/client-generator/src/emitters/transformers.ts
rename to packages/client-generator/src/generators/transformers/render.ts
index b913f91fcf..917e48419b 100644
--- a/packages/client-generator/src/emitters/transformers.ts
+++ b/packages/client-generator/src/generators/transformers/render.ts
@@ -9,13 +9,8 @@
// `transformPet` calls `transformOwner(data["owner"])` when `Pet.owner` is an
// `Owner` that has dates. Source-text templates throughout.
-import type {
- ApiModel,
- NamedSchemaModel,
- SchemaModel,
-} from '../intermediate-representation/model.js';
-import { safeIdent } from './identifier.js';
-import { pascalCase } from './support.js';
+import type { ApiModel, NamedSchemaModel, SchemaModel } from '@redocly/client-generator';
+import { pascalCase, safeIdent } from '@redocly/client-generator/printers/typescript';
const INDENT = ' ';
diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts
index 331d26d34a..4c3c5f95b9 100644
--- a/packages/client-generator/src/generators/types.ts
+++ b/packages/client-generator/src/generators/types.ts
@@ -1,8 +1,88 @@
-// packages/client-generator/src/generators/types.ts
-import type { EmitOptions } from '../emitters/emit-options.js';
-import type { ErrorMode } from '../emitters/operations.js';
-import type { DateType } from '../emitters/types.js';
+import type { DateType } from '../authoring/options.js';
import type { ApiModel, OperationModel } from '../intermediate-representation/model.js';
+import type { ModelPagination } from '../pagination.js';
+
+export type { DateType } from '../authoring/options.js';
+
+/** Error-handling shape of the generated client: throw on non-2xx, or return a result union. */
+export type ErrorMode = 'throw' | 'result';
+
+/**
+ * How an operation's inputs are passed to the generated call.
+ * - `'flat'` (default): path params spread as positional args, then the
+ * `params`/`body`/`headers` slots — one exported sugar arrow per operation.
+ * - `'grouped'`: the client methods' own shape — a single `args` object bundling
+ * every input; the sugar is a plain destructure of the client. The per-call
+ * `init: RequestOptions` stays a separate trailing argument in both styles.
+ */
+export type ArgsStyle = 'flat' | 'grouped';
+
+export type EmitOptions = {
+ /**
+ * Override the server URL baked into the generated client config. When omitted,
+ * the value is derived from `servers[0].url` in the source OpenAPI description.
+ */
+ serverUrl?: string;
+ /**
+ * How operation inputs are passed to each call. Defaults to `'flat'`;
+ * `'grouped'` bundles inputs into a single `args` object.
+ */
+ argsStyle?: ArgsStyle;
+ /** Error-handling shape of the generated client. Defaults to `'throw'`. */
+ errorMode?: 'throw' | 'result';
+ /**
+ * How `format: date-time`/`date` string fields are typed. `'string'` (default)
+ * keeps the ISO wire shape; `'Date'` emits a `Date` reference. Opt-in — pair with
+ * the `transformers` generator so the runtime value matches the type.
+ */
+ dateType?: DateType;
+ /**
+ * How the `mock` generator produces data. `'static'` (default) inlines deterministic
+ * literals (zero-dep, contract-faithful); `'faker'` emits `@faker-js/faker` calls for
+ * realistic data — reproducible when `mockSeed` is set. Only the mock module is affected.
+ */
+ mockData?: 'static' | 'faker';
+ /** Seed for faker-mode mocks: emits a top-level `faker.seed()` so runs reproduce. */
+ mockSeed?: number;
+ /** Leading element for every tanstack-query key — namespaces the cache when several
+ * generated APIs share one QueryClient (operationIds may collide across APIs). */
+ queryKeyPrefix?: string;
+ /**
+ * A pre-baked publisher setup block (from `bakeSetup`) merged into the client's config
+ * via `mergeSetup`. Absent when no `--setup` is given.
+ */
+ setup?: string;
+ /** Runtime distribution: `'inline'` (default) embeds the runtime in the generated
+ * file; `'module'` writes it as real files in a `runtime/` folder beside the client. */
+ runtime?: 'inline' | 'module';
+ /**
+ * Extension used in generated relative import specifiers (the split entry's schemas
+ * re-export and each satellite's sdk import). `'js'` (default) is the tsc/bundler
+ * convention; `'ts'` targets runtimes that resolve specifiers literally, like Node's
+ * built-in type stripping (`node client.ts`).
+ */
+ importExt?: 'js' | 'ts';
+ /**
+ * Package clause of the `go` generator's output. Defaults to `client` — a generated
+ * file usually lands in a package the consumer already owns, so the name is theirs
+ * to choose. An invalid Go package name fails generation.
+ */
+ goPackage?: string;
+ /**
+ * Auto-pagination RESOLVED by the pipeline (fit-verified, one answer per run),
+ * resolved together with each operation's `x-redoclyPagination` extension. Verified
+ * statically: an explicit rule that doesn't fit its operation fails generation.
+ */
+ pagination?: ModelPagination;
+ /**
+ * Also write the reference documentation for what each selected generator emits: one
+ * Markdown page per generator that implements the `docs` hook. One switch for the whole
+ * run, so a new documented language never needs a new flag.
+ */
+ docs?: boolean;
+ /** Emit YAML front matter carrying the title above each documentation page. */
+ docsFrontmatter?: boolean;
+};
/**
* How the generated client is partitioned across files.
@@ -52,11 +132,30 @@ export type GeneratorOptionsSchema = {
additionalProperties?: boolean;
};
+/**
+ * The `--output` anchor, parsed once by the pipeline: the full `path`, its `dir`,
+ * the `stem` (base name without the final extension), and the `ext` (with the dot).
+ * Generators derive sibling-file names from these instead of re-parsing the path.
+ */
+export type OutputAnchor = { path: string; dir: string; stem: string; ext: string };
+
/** Everything a generator needs to produce its files. */
export type GeneratorInput = {
model: ApiModel;
- /** The `--output` anchor path. */
- outputPath: string;
+ /** The `--output` anchor, parsed (see `OutputAnchor`). */
+ output: OutputAnchor;
+ /**
+ * The generated-by banner lines, free of comment markers — each generator prepends
+ * them in its own comment syntax, so every emitted file says the same thing.
+ */
+ banner: string[];
+ /**
+ * Pagination resolved ONCE by the pipeline — per-op config > `x-redoclyPagination` >
+ * convention, fit-verified, pointers resolved — keyed by operation name. Generators
+ * read this instead of re-resolving, so two of them cannot disagree about whether an
+ * operation paginates.
+ */
+ pagination?: ModelPagination;
/** File partitioning the generator should honor. */
outputMode: OutputMode;
/** Emit options — serverUrl, runtime, and the generator knobs (dateType, mockData, …); see `EmitOptions`. */
@@ -87,7 +186,13 @@ export type CodeSample = { lang: string; label?: string; source: string };
* derives that name from the anchor its own way (`openapi.client.ts` becomes
* `openapi_client.py`), so a hardcoded module name is wrong for most stems.
*/
-export type SampleContext = { model: ApiModel; emit: EmitOptions; outputPath: string };
+export type SampleContext = {
+ model: ApiModel;
+ emit: EmitOptions;
+ outputPath: string;
+ /** The run's resolved pagination (see `GeneratorInput.pagination`). */
+ pagination?: ModelPagination;
+};
/**
* A generator plus its declared compatibility contract. `validateGenerators`
@@ -96,7 +201,7 @@ export type SampleContext = { model: ApiModel; emit: EmitOptions; outputPath: st
*
* - `requires`: other generators that must also be selected (e.g. `tanstack-query`
* imports the client's operation functions, so it requires `typescript`).
- * - `errorModes` / `dateTypes` / `runtimes`: the subset this generator supports;
+ * - `errorModes` / `dateTypes`: the subset this generator supports;
* `undefined` means "all". (`tanstack-query` wraps throw-mode functions, so it
* supports only `throw` mode; `transformers` only type-checks when the client types
* date fields as `Date`, so it supports only `dateType: 'Date'`.)
@@ -121,8 +226,6 @@ export type GeneratorDescriptor = {
requires?: string[];
errorModes?: ErrorMode[];
dateTypes?: DateType[];
- /** Runtime modes this generator supports; absent = compatible with both. */
- runtimes?: ('inline' | 'package')[];
/**
* Options this generator does not apply, mapped to the reason it doesn't. Setting
* one explicitly warns instead of being silently dropped — a global option
diff --git a/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap b/packages/client-generator/src/generators/typescript/__tests__/__snapshots__/client-assembly.test.ts.snap
similarity index 87%
rename from packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap
rename to packages/client-generator/src/generators/typescript/__tests__/__snapshots__/client-assembly.test.ts.snap
index 2160d2aa2c..b0d1ebaa32 100644
--- a/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap
+++ b/packages/client-generator/src/generators/typescript/__tests__/__snapshots__/client-assembly.test.ts.snap
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`emitClientSingleFile (package arm) > matches the golden output for a small model 1`] = `
+exports[`emitClientSingleFile (wiring) > matches the golden output for a small model 1`] = `
"// Generated by @redocly/client-generator — do not edit by hand.
// Source: OpenAPI description. Re-run \`redocly generate-client\` to update.
@@ -8,8 +8,6 @@ exports[`emitClientSingleFile (package arm) > matches the golden output for a sm
* T (v1.0.0)
*/
-import { createClient, type OperationDescriptor } from '@redocly/client-generator';
-
export type Order = {
id: string;
};
@@ -76,13 +74,10 @@ export const client = createClient matches the golden output for a paginated package client 1`] = `
+exports[`emitClientSingleFile — pagination > matches the golden output for a paginated client (wiring only) 1`] = `
"// Generated by @redocly/client-generator — do not edit by hand.
// Source: OpenAPI description. Re-run \`redocly generate-client\` to update.
@@ -90,8 +85,6 @@ exports[`emitClientSingleFile — pagination > matches the golden output for a p
* T (v1.0.0)
*/
-import { createClient, type OperationDescriptor } from '@redocly/client-generator';
-
export type Order = {};
export type Problem = {};
@@ -174,13 +167,10 @@ export const client = createClient matches the golden output for a result-mode paginated package client 1`] = `
+exports[`emitClientSingleFile — pagination > matches the golden output for a result-mode paginated client (wiring only) 1`] = `
"// Generated by @redocly/client-generator — do not edit by hand.
// Source: OpenAPI description. Re-run \`redocly generate-client\` to update.
@@ -188,8 +178,6 @@ exports[`emitClientSingleFile — pagination > matches the golden output for a r
* T (v1.0.0)
*/
-import { createClient, type OperationDescriptor, type Result } from '@redocly/client-generator';
-
export type Order = {};
export type Problem = {};
@@ -277,8 +265,5 @@ export const client = createClient {
it('joins non-empty sections with blank lines and appends a trailing newline', () => {
diff --git a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts b/packages/client-generator/src/generators/typescript/__tests__/client-assembly.test.ts
similarity index 78%
rename from packages/client-generator/src/emitters/__tests__/client-assembly.test.ts
rename to packages/client-generator/src/generators/typescript/__tests__/client-assembly.test.ts
index c08fcdfdf7..55ef49777c 100644
--- a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts
+++ b/packages/client-generator/src/generators/typescript/__tests__/client-assembly.test.ts
@@ -1,13 +1,30 @@
import ts from 'typescript';
-import type { ApiModel } from '../../intermediate-representation/model.js';
-import { emitClientSingleFile } from '../client-assembly.js';
-import type { EmitOptions } from '../emit-options.js';
-import { modelWith, namedSchema, operation, param, response, SCALAR } from './fixtures.js';
-
-/** The package arm of the shared emitter. */
+import {
+ modelWith,
+ namedSchema,
+ operation,
+ param,
+ response,
+ SCALAR,
+} from '../../../__tests__/fixtures.js';
+import type { ApiModel } from '../../../intermediate-representation/model.js';
+import { resolveModelPagination } from '../../../pagination.js';
+import type { EmitOptions } from '../../types.js';
+import { emitClientSingleFile, emitRuntimeFiles } from '../client-assembly.js';
+
+/**
+ * The emitted client with the embedded runtime block cut out, so assertions and the
+ * golden snapshots cover the wiring this file OWNS — the runtime bytes are pinned by
+ * runtime-sources.test.ts, and full inline output by the e2e cafe snapshot.
+ */
function emit(model: ApiModel, options: EmitOptions = {}): string {
- return emitClientSingleFile(model, { ...options, runtime: 'package' });
+ const out = emitClientSingleFile(model, options);
+ const start = out.indexOf('// ─── Embedded runtime');
+ if (start === -1) return out;
+ const setup = out.indexOf('// ─── Baked-in setup', start);
+ const end = setup !== -1 ? setup : out.indexOf('export const client =', start);
+ return out.slice(0, start) + out.slice(end);
}
const getOrder = operation({
@@ -87,20 +104,9 @@ const CAFE = modelWith([getOrder, createPet, upload, streamEvents, configureOp],
],
});
-describe('emitClientSingleFile (package arm)', () => {
+describe('emitClientSingleFile (wiring)', () => {
const output = emit(CAFE, { serverUrl: 'https://x' });
- it('imports from the package instead of inlining the runtime template', () => {
- // Only the names the file references. The per-call option types went with the flat
- // wrappers, and an unused type import fails a consumer's `noUnusedLocals` build.
- expect(output).toContain(
- "import { createClient, type OperationDescriptor } from '@redocly/client-generator';"
- );
- expect(output).not.toContain('__send');
- expect(output).not.toContain('__buildUrl');
- expect(output).not.toContain('let BASE');
- });
-
it('escapes U+2028/U+2029 in generated string literals (code-shape hardening)', () => {
const out = emit(
modelWith([getOrder], {
@@ -180,15 +186,6 @@ describe('emitClientSingleFile (package arm)', () => {
expect(output).toContain('configure_2 } = client;');
});
- it('re-exports the public surface', () => {
- expect(output).toContain(
- "export { ApiError, createClient, defaultRetryOn, TimeoutError } from '@redocly/client-generator';"
- );
- expect(output).toContain(
- "export type { ClientConfig, Envelope, Middleware, RequestOptions, ServerSentEvent, SseOptions } from '@redocly/client-generator';"
- );
- });
-
it('keys a path value by its WIRE name, which is what the runtime substitutes', () => {
const model = modelWith([
operation({
@@ -221,14 +218,11 @@ describe('emitClientSingleFile (package arm)', () => {
);
});
- it('layers a baked setup OVER the spec defaults and imports the contract types', () => {
+ it('layers a baked setup OVER the spec defaults', () => {
const out = emit(modelWith([getOrder], { schemas: SCHEMAS }), {
serverUrl: 'https://x',
setup: '{ config: { retry: { retries: 2 } } }',
});
- expect(out).toContain(
- "import { createClient, mergeSetup, type ClientConfig, type Middleware, type OperationDescriptor } from '@redocly/client-generator';"
- );
expect(out).toContain(
'const __redoclySetup: { config?: ClientConfig; middleware?: Middleware[] } = { config: { retry: { retries: 2 } } };'
);
@@ -238,14 +232,10 @@ describe('emitClientSingleFile (package arm)', () => {
);
});
- it('result mode with an SSE-only spec does not import the (unreferenced) Result type', () => {
+ it('result mode with an SSE-only spec keeps the SSE member unwrapped', () => {
const out = emit(modelWith([streamEvents], { schemas: SCHEMAS }), { errorMode: 'result' });
- expect(out).not.toContain('type Result');
- // The SSE member stays unwrapped, and the re-export list still offers Result.
expect(out).toContain('kind: "sse"');
- expect(out).toContain(
- "export type { ClientConfig, Envelope, Middleware, RequestOptions, Result, ServerSentEvent, SseOptions } from '@redocly/client-generator';"
- );
+ expect(out).not.toContain('result: Result<');
});
it('bakes errorMode: result into the config and wraps Ops results', () => {
@@ -257,7 +247,6 @@ describe('emitClientSingleFile (package arm)', () => {
'{ serverUrl: "https://x", errorMode: "result", clientHeader: "redocly-client-generator" }'
);
expect(out).toContain('result: Result;');
- expect(out).toContain('type Result');
});
it('argsStyle: flat merges the inputs and tells the runtime, keeping one binding', () => {
@@ -337,7 +326,7 @@ describe('emitClientSingleFile (package arm)', () => {
});
});
-describe('emitClientSingleFile (embed arm)', () => {
+describe('emitClientSingleFile (embedded runtime)', () => {
const output = emitClientSingleFile(CAFE, { serverUrl: 'https://x' });
it('embeds the runtime block instead of importing the package', () => {
@@ -420,31 +409,79 @@ describe('emitClientSingleFile (embed arm)', () => {
expect((sourceFile as unknown as { parseDiagnostics: unknown[] }).parseDiagnostics).toEqual([]);
});
- it('emits wiring (Ops → OPERATIONS, client → sugar) byte-identical to the package arm', () => {
- const packaged = emit(CAFE, { serverUrl: 'https://x' });
- // `'export type Ops ='` — the trailing `=` skips the embedded `export type OpsShape`.
- // In embed mode the runtime block sits between OPERATIONS and `client`, so the
- // wiring is compared as its two contiguous segments around it.
- const slice = (out: string, from: string, to: number) => out.slice(out.indexOf(from), to);
- expect(
- slice(output, 'export type Ops =', output.indexOf('// ─── Embedded runtime')).trim()
- ).toBe(slice(packaged, 'export type Ops =', packaged.indexOf('export const client')).trim());
- expect(slice(output, 'export const client', output.length).trim()).toBe(
- slice(packaged, 'export const client', packaged.indexOf('export { ApiError,')).trim()
- );
- });
-
// The full inline output is not snapshotted here: the runtime bytes are pinned by
- // runtime-sources.test.ts, the wiring by the byte-identity test above, and a real
+ // runtime-sources.test.ts, the wiring by the trimmed snapshots above, and a real
// full inline client by the e2e cafe.snapshot.ts.
});
+describe('runtime: module', () => {
+ it('writes the per-needs modules + the factory, and the entry imports them relatively', () => {
+ const files = emitRuntimeFiles(CAFE, { runtime: 'module' });
+ // CAFE needs multipart, auth, and sse — no setup, no pagination.
+ expect(files.map((file) => file.name)).toEqual([
+ 'types.ts',
+ 'errors.ts',
+ 'url.ts',
+ 'parse.ts',
+ 'retry.ts',
+ 'multipart.ts',
+ 'auth.ts',
+ 'send.ts',
+ 'sse.ts',
+ 'create-client.ts',
+ 'factory.ts',
+ ]);
+ const factory = files.find((file) => file.name === 'factory.ts')!.content;
+ expect(factory).toContain("import { createClientCore } from './create-client.js';");
+ expect(factory).toContain("import { toFormData } from './multipart.js';");
+ expect(factory).toContain("import { resolveAuth } from './auth.js';");
+ expect(factory).toContain("import { sse } from './sse.js';");
+ expect(factory).not.toContain("from './paginate.js'");
+ expect(factory).toContain("export { ApiError, TimeoutError } from './errors.js';");
+ expect(factory).toContain("export type * from './types.js';");
+ // The raw modules keep their imports — nothing is stripped in module mode.
+ const send = files.find((file) => file.name === 'send.ts')!.content;
+ expect(send).toContain("from './errors.js'");
+
+ const entry = emitClientSingleFile(CAFE, { runtime: 'module' });
+ expect(entry).toContain("import { createClient } from './runtime/factory.js';");
+ expect(entry).toContain("import type { OperationDescriptor } from './runtime/types.js';");
+ expect(entry).toContain("export * from './runtime/factory.js';");
+ expect(entry).not.toContain('// ─── Embedded runtime');
+ });
+
+ it('inline mode writes no runtime files', () => {
+ expect(emitRuntimeFiles(CAFE, {})).toEqual([]);
+ });
+
+ it('importExt ts rewrites the intra-runtime specifiers so Node can strip types directly', () => {
+ const files = emitRuntimeFiles(CAFE, { runtime: 'module', importExt: 'ts' });
+ const factory = files.find((file) => file.name === 'factory.ts')!.content;
+ expect(factory).toContain("import { createClientCore } from './create-client.ts';");
+ expect(factory).not.toContain(".js'");
+ const entry = emitClientSingleFile(CAFE, { runtime: 'module', importExt: 'ts' });
+ expect(entry).toContain("import { createClient } from './runtime/factory.ts';");
+ });
+
+ it('a baked setup imports mergeSetup and the config types from the runtime', () => {
+ const entry = emitClientSingleFile(CAFE, { runtime: 'module', setup: '{}' });
+ expect(entry).toContain("import { createClient, mergeSetup } from './runtime/factory.js';");
+ expect(entry).toContain('ClientConfig');
+ const files = emitRuntimeFiles(CAFE, { runtime: 'module', setup: '{}' });
+ expect(files.find((file) => file.name === 'setup.ts')).toBeDefined();
+ expect(files.find((file) => file.name === 'factory.ts')!.content).toContain(
+ "export { mergeSetup } from './setup.js';"
+ );
+ });
+});
+
describe('emitClientSingleFile — pagination', () => {
const PAGINATED = modelWith([listOrders, getOrder], { schemas: [...SCHEMAS, ORDER_PAGE] });
const config = { operations: { listOrders: CURSOR_RULE } };
+ const pagination = resolveModelPagination(PAGINATED, config);
- it('threads a config rule into the descriptor and the Ops item member (package arm)', () => {
- const out = emit(PAGINATED, { pagination: config });
+ it('threads a config rule into the descriptor and the Ops item member', () => {
+ const out = emit(PAGINATED, { pagination });
expect(out).toContain(
'pagination: { style: "cursor", param: "cursor", nextCursor: "/nextCursor", items: "/orders" }'
);
@@ -457,13 +494,13 @@ describe('emitClientSingleFile — pagination', () => {
const model = modelWith([{ ...listOrders, paginationExtension: CURSOR_RULE }, getOrder], {
schemas: [...SCHEMAS, ORDER_PAGE],
});
- const out = emit(model);
+ const out = emit(model, { pagination: resolveModelPagination(model, undefined) });
expect(out).toContain('item: Order;');
expect(out).toContain('pagination: { style: "cursor", param: "cursor",');
});
it('the iterators ride the binding, so `.pages`/`.items` need no wrapper', () => {
- const out = emit(PAGINATED, { pagination: config });
+ const out = emit(PAGINATED, { pagination });
// `listOrders` is the client method itself, which carries `.pages`/`.items` — there is
// nothing to re-wrap, and therefore no second argument shape to get wrong.
expect(out).toContain('export const { listOrders, getOrder } = client;');
@@ -472,7 +509,7 @@ describe('emitClientSingleFile — pagination', () => {
});
it('grouped argsStyle needs no wrapper — properties ride along on the destructure', () => {
- const out = emit(PAGINATED, { pagination: config, argsStyle: 'grouped' });
+ const out = emit(PAGINATED, { pagination, argsStyle: 'grouped' });
expect(out).toContain('export const { listOrders, getOrder } = client;');
expect(out).not.toContain('Object.assign');
});
@@ -480,7 +517,9 @@ describe('emitClientSingleFile — pagination', () => {
it('embeds the paginate capability in inline mode only when a descriptor paginates', () => {
// A security-free model, so paginate is the ONLY capability in the factory wiring.
const model = modelWith([listOrders], { schemas: [SCHEMAS[0], ORDER_PAGE] });
- const paginated = emitClientSingleFile(model, { pagination: config });
+ const paginated = emitClientSingleFile(model, {
+ pagination: resolveModelPagination(model, config),
+ });
expect(paginated).toContain('async function* pages');
expect(paginated).toContain(
'createClientCore(operations, config, { paginate: { pages, items, pagesByLink, itemsByLink } })'
@@ -503,7 +542,7 @@ describe('emitClientSingleFile — pagination', () => {
],
{ schemas: [...SCHEMAS, ORDER_PAGE] }
);
- expect(() => emitClientSingleFile(model)).toThrow(
+ expect(() => resolveModelPagination(model, undefined)).toThrow(
'Invalid pagination configuration:\n' +
' - Pagination for operation "listOrders" (x-redoclyPagination): ' +
'query parameter "after" is not declared on the operation (declared: cursor, limit)\n' +
@@ -512,13 +551,13 @@ describe('emitClientSingleFile — pagination', () => {
);
});
- it('matches the golden output for a paginated package client', () => {
- expect(emit(PAGINATED, { pagination: config })).toMatchSnapshot();
+ it('matches the golden output for a paginated client (wiring only)', () => {
+ expect(emit(PAGINATED, { pagination })).toMatchSnapshot();
});
- it('matches the golden output for a result-mode paginated package client', () => {
+ it('matches the golden output for a result-mode paginated client (wiring only)', () => {
// Result mode: the Ops entry gains `page` (the raw page `.pages()` yields) next to
// the envelope-wrapped `result`.
- expect(emit(PAGINATED, { pagination: config, errorMode: 'result' })).toMatchSnapshot();
+ expect(emit(PAGINATED, { pagination, errorMode: 'result' })).toMatchSnapshot();
});
});
diff --git a/packages/client-generator/src/emitters/__tests__/descriptor.test.ts b/packages/client-generator/src/generators/typescript/__tests__/descriptor.test.ts
similarity index 98%
rename from packages/client-generator/src/emitters/__tests__/descriptor.test.ts
rename to packages/client-generator/src/generators/typescript/__tests__/descriptor.test.ts
index 9b0c5bd1c0..7b886d895a 100644
--- a/packages/client-generator/src/emitters/__tests__/descriptor.test.ts
+++ b/packages/client-generator/src/generators/typescript/__tests__/descriptor.test.ts
@@ -1,13 +1,12 @@
+import { apiModel, modelWith, operation, param } from '../../../__tests__/fixtures.js';
import type {
ApiModel,
OperationModel,
ResponseBodyModel,
-} from '../../intermediate-representation/model.js';
+} from '../../../intermediate-representation/model.js';
+import type { ModelPagination } from '../../../pagination.js';
import { packageIdents, renderDescriptors } from '../descriptor.js';
-import type { EmitContext } from '../operations.js';
-import type { ModelPagination } from '../pagination.js';
-import { renderOpsType } from '../render-client.js';
-import { apiModel, modelWith, operation, param } from './fixtures.js';
+import { type EmitContext, renderOpsType } from '../render-client.js';
function emitDescriptors(model: ApiModel): string {
return renderDescriptors(model, packageIdents(model), 'string');
diff --git a/packages/client-generator/src/emitters/__tests__/inline-runtime.test.ts b/packages/client-generator/src/generators/typescript/__tests__/inline-runtime.test.ts
similarity index 100%
rename from packages/client-generator/src/emitters/__tests__/inline-runtime.test.ts
rename to packages/client-generator/src/generators/typescript/__tests__/inline-runtime.test.ts
diff --git a/packages/client-generator/src/emitters/__tests__/operations.test.ts b/packages/client-generator/src/generators/typescript/__tests__/operations.test.ts
similarity index 98%
rename from packages/client-generator/src/emitters/__tests__/operations.test.ts
rename to packages/client-generator/src/generators/typescript/__tests__/operations.test.ts
index 6dd560bad8..d7ec563d55 100644
--- a/packages/client-generator/src/emitters/__tests__/operations.test.ts
+++ b/packages/client-generator/src/generators/typescript/__tests__/operations.test.ts
@@ -1,9 +1,19 @@
+import {
+ SCALAR,
+ apiModel,
+ emitWithOp,
+ namedSchema,
+ operation,
+ param,
+} from '../../../__tests__/fixtures.js';
// One operation's developer-facing surface in the descriptor-wired single-file client:
// the input shape in both styles, and the `*` aliases. The wiring itself (Ops,
// OPERATIONS, client, sugar) is covered in client-assembly.test.ts.
-import type { OperationModel, RequestBodyModel } from '../../intermediate-representation/model.js';
+import type {
+ OperationModel,
+ RequestBodyModel,
+} from '../../../intermediate-representation/model.js';
import { emitClientSingleFile } from '../client-assembly.js';
-import { SCALAR, apiModel, emitWithOp, namedSchema, operation, param } from './fixtures.js';
/** Emit a result-mode single-file client whose only operation is `operation(op)`. */
function emitResult(op: Partial, schemas: string[] = []): string {
diff --git a/packages/client-generator/src/emitters/__tests__/ts-type.test.ts b/packages/client-generator/src/generators/typescript/__tests__/ts-type.test.ts
similarity index 97%
rename from packages/client-generator/src/emitters/__tests__/ts-type.test.ts
rename to packages/client-generator/src/generators/typescript/__tests__/ts-type.test.ts
index 12b48fecae..75d8ec3562 100644
--- a/packages/client-generator/src/emitters/__tests__/ts-type.test.ts
+++ b/packages/client-generator/src/generators/typescript/__tests__/ts-type.test.ts
@@ -1,4 +1,4 @@
-import type { NamedSchemaModel, SchemaModel } from '../../intermediate-representation/model.js';
+import type { NamedSchemaModel, SchemaModel } from '../../../intermediate-representation/model.js';
import { renderTypeAliases, tsType } from '../ts-type.js';
// Literal expectations for the TS type renderer — the formatting contract every
diff --git a/packages/client-generator/src/emitters/__tests__/type-guards.test.ts b/packages/client-generator/src/generators/typescript/__tests__/type-guards.test.ts
similarity index 92%
rename from packages/client-generator/src/emitters/__tests__/type-guards.test.ts
rename to packages/client-generator/src/generators/typescript/__tests__/type-guards.test.ts
index 698d044d30..bf4b198035 100644
--- a/packages/client-generator/src/emitters/__tests__/type-guards.test.ts
+++ b/packages/client-generator/src/generators/typescript/__tests__/type-guards.test.ts
@@ -1,11 +1,16 @@
-import type { NamedSchemaModel, SchemaModel } from '../../intermediate-representation/model.js';
+import { apiModel, namedSchema } from '../../../__tests__/fixtures.js';
+import type { NamedSchemaModel, SchemaModel } from '../../../intermediate-representation/model.js';
import { emitClientSingleFile } from '../client-assembly.js';
-import { apiModel, namedSchema } from './fixtures.js';
-// The package arm keeps the emitted text free of the embedded runtime, so the
-// absence assertions below test the schema types/guards alone.
-const emitPackage: typeof emitClientSingleFile = (model, options = {}) =>
- emitClientSingleFile(model, { ...options, runtime: 'package' });
+// Cutting the embedded runtime block keeps the emitted text down to the schema
+// types/guards these assertions target (the runtime bytes are pinned elsewhere).
+const emitWiring: typeof emitClientSingleFile = (model, options = {}) => {
+ const out = emitClientSingleFile(model, options);
+ const start = out.indexOf('// ─── Embedded runtime');
+ return start === -1
+ ? out
+ : out.slice(0, start) + out.slice(out.indexOf('export const client =', start));
+};
describe('discriminated-union type guards (C6.4)', () => {
const beverage = namedSchema('Beverage', {
@@ -30,7 +35,7 @@ describe('discriminated-union type guards (C6.4)', () => {
});
it('emits is() guards for an explicit discriminator', () => {
- const out = emitPackage(
+ const out = emitWiring(
apiModel({
schemas: [
beverage,
@@ -59,7 +64,7 @@ describe('discriminated-union type guards (C6.4)', () => {
});
it('skips a discriminator entry whose target is not a named schema', () => {
- const out = emitPackage(
+ const out = emitWiring(
apiModel({
schemas: [
beverage,
@@ -82,7 +87,7 @@ describe('discriminated-union type guards (C6.4)', () => {
});
it('emits a single guard when two discriminant values map to the same type', () => {
- const out = emitPackage(
+ const out = emitWiring(
apiModel({
schemas: [
namedSchema('Pet', { kind: 'object', properties: [] }),
@@ -112,7 +117,7 @@ describe('discriminated-union type guards (C6.4)', () => {
});
it('synthesizes an implicit discriminator from a shared distinct string const', () => {
- const out = emitPackage(
+ const out = emitWiring(
apiModel({
schemas: [
beverage,
@@ -132,7 +137,7 @@ describe('discriminated-union type guards (C6.4)', () => {
});
it('finds the implicit discriminant through intersection (allOf) members', () => {
- const out = emitPackage(
+ const out = emitWiring(
apiModel({
schemas: [
namedSchema('A', {
@@ -297,11 +302,11 @@ describe('discriminated-union type guards (C6.4)', () => {
],
],
])('emits no guards when %s', (_reason, schemas) => {
- expect(emitPackage(apiModel({ schemas }))).not.toContain('value is');
+ expect(emitWiring(apiModel({ schemas }))).not.toContain('value is');
});
it('ignores non-literal properties while detecting the implicit discriminant', () => {
- const out = emitPackage(
+ const out = emitWiring(
apiModel({
schemas: [
namedSchema('R1', {
@@ -374,7 +379,7 @@ describe('discriminated-union type guards (C6.4)', () => {
{ kind: 'object', properties: [{ name: 'pet', schema: catOrDog, required: true }] },
],
])('emits guards for a discriminated union nested under %s', (_position, container) => {
- const out = emitPackage(apiModel({ schemas: [cat, dog, namedSchema('PetBox', container)] }));
+ const out = emitWiring(apiModel({ schemas: [cat, dog, namedSchema('PetBox', container)] }));
expect(out).toContain('export function isCat(value: Cat | Dog): value is Cat {');
expect(out).toContain('export function isDog(value: Cat | Dog): value is Dog {');
});
@@ -403,7 +408,7 @@ describe('discriminated-union type guards (C6.4)', () => {
},
},
});
- const out = emitPackage(
+ const out = emitWiring(
apiModel({
schemas: [
item('Ok', 'ok'),
@@ -418,7 +423,7 @@ describe('discriminated-union type guards (C6.4)', () => {
});
it('prefers the top-level named union param when a member also nests elsewhere', () => {
- const out = emitPackage(
+ const out = emitWiring(
apiModel({
schemas: [
beverage,
diff --git a/packages/client-generator/src/generators/typescript/banner.ts b/packages/client-generator/src/generators/typescript/banner.ts
new file mode 100644
index 0000000000..c5ab93f8cd
--- /dev/null
+++ b/packages/client-generator/src/generators/typescript/banner.ts
@@ -0,0 +1,31 @@
+import type { ApiModel } from '@redocly/client-generator';
+import { escapeJsDoc, splitLines } from '@redocly/client-generator/printers/typescript';
+
+/** The generated-by banner prepended to every emitted module. */
+export const HEADER = `// Generated by @redocly/client-generator — do not edit by hand.
+// Source: OpenAPI description. Re-run \`redocly generate-client\` to update.`;
+
+/**
+ * Assemble file content from a header banner and a printed body: the leading
+ * `// Generated by …` comment and the `/** title */` block are structural
+ * banners (not part of the printed AST), prepended with blank-line separation.
+ * Trailing newline mirrors a hand-authored file.
+ */
+export function banner(sections: string[]): string {
+ return sections.filter((s) => s.length > 0).join('\n\n') + '\n';
+}
+
+export function renderTitleComment(model: ApiModel): string {
+ // This banner is a raw string (it does not flow through `jsdoc()`), so escape
+ // `*/` here too — `info.title`/`info.description` are attacker-controllable and
+ // would otherwise close the comment and inject code at the top of every file.
+ const lines = [`/**`, ` * ${escapeJsDoc(`${model.title} (v${model.version})`)}`];
+ if (model.description) {
+ for (const line of splitLines(escapeJsDoc(model.description))) {
+ // A blank line prints as ` *` — a trailing space fails consumer formatter checks.
+ lines.push(` * ${line}`.replace(/ +$/, ''));
+ }
+ }
+ lines.push(' */');
+ return lines.join('\n');
+}
diff --git a/packages/client-generator/src/emitters/client-assembly.ts b/packages/client-generator/src/generators/typescript/client-assembly.ts
similarity index 61%
rename from packages/client-generator/src/emitters/client-assembly.ts
rename to packages/client-generator/src/generators/typescript/client-assembly.ts
index b47f7c9693..e0f2a5e9fd 100644
--- a/packages/client-generator/src/emitters/client-assembly.ts
+++ b/packages/client-generator/src/generators/typescript/client-assembly.ts
@@ -1,38 +1,87 @@
-// Client assembly, shared by both runtime distributions and both output modes. The
-// wiring (descriptor map + `Ops` interface) is identical; only the runtime block
-// differs — `runtime: 'package'` imports `createClient` from
-// `@redocly/client-generator`, everything else (inline, the default) embeds the
-// assembled runtime sources in its place (emitters/inline-runtime.ts). Single-file
-// layout: runtime (import line | embedded block) → schema types → type guards →
-// `*` aliases → Ops → OPERATIONS → (baked setup) → client instance → sugar →
-// (package mode only) type re-exports — the embedded types are already exported in
-// place, so the embed arm needs none. Split mode moves the schema types + guards into
-// a sibling `.schemas.ts` the entry re-exports (`emitClientSplit`).
+// Client assembly, shared by both output modes. The generated file embeds the
+// assembled runtime sources (./inline-runtime.ts). Single-file layout:
+// schema types → type guards → `*` aliases → Ops → OPERATIONS → embedded
+// runtime → (baked setup) → client instance → sugar — the embedded types are
+// already exported in place, so no re-exports. Split mode moves the schema types +
+// guards into a sibling `.schemas.ts` the entry re-exports (`emitClientSplit`).
// Text templates throughout — no `typescript` at generate time.
import {
allOperations,
type ApiModel,
+ type EmitOptions,
type OperationModel,
-} from '../intermediate-representation/model.js';
-import { packageIdents, renderDescriptors } from './descriptor.js';
-import { banner, type EmitOptions, HEADER, renderTitleComment } from './emit-options.js';
-import { codeString } from './identifier.js';
-import { assembleInlineRuntime } from './inline-runtime.js';
-import { isTypedMultipart } from './operation-types.js';
-import type { EmitContext } from './operations.js';
-import { resolveModelPagination } from './pagination.js';
-import { collectEntrySchemaRefs, renderAliases, renderOpsType } from './render-client.js';
-import { isSseOp } from './sse.js';
-import { renderTypeAliases } from './ts-type.js';
-import { renderTypeGuards } from './type-guards.js';
+} from '@redocly/client-generator';
+import { codeString } from '@redocly/client-generator/printers/typescript';
-const PACKAGE_SPECIFIER = '@redocly/client-generator';
+import { banner, HEADER, renderTitleComment } from './banner.ts';
+import { packageIdents, renderDescriptors } from './descriptor.ts';
+import {
+ assembleInlineRuntime,
+ type InlineRuntimeNeeds,
+ runtimeModuleFiles,
+} from './inline-runtime.ts';
+import { isTypedMultipart } from './operation-types.ts';
+import {
+ collectEntrySchemaRefs,
+ type EmitContext,
+ renderAliases,
+ renderOpsType,
+} from './render-client.ts';
+import { renderTypeAliases } from './ts-type.ts';
+import { renderTypeGuards } from './type-guards.ts';
export function emitClientSingleFile(model: ApiModel, options: EmitOptions = {}): string {
return emitClient(model, options).entry;
}
+/** Which optional runtime capabilities this API needs (drives both distribution modes). */
+export function runtimeNeeds(model: ApiModel, options: EmitOptions): InlineRuntimeNeeds {
+ const ops = allOperations(model.services);
+ return {
+ multipart: ops.some((op) => op.requestBody && isTypedMultipart(op.requestBody)),
+ // Auth sugar needs schemes; `resolveAuth` fires when a descriptor carries
+ // `security` — a valid spec implies the former, but embed on either.
+ auth: model.securitySchemes.length > 0 || ops.some((op) => op.security.length > 0),
+ sse: ops.some((op) => op.sse !== undefined),
+ setup: !!options.setup,
+ paginate: (options.pagination ?? new Map()).size > 0,
+ };
+}
+
+/**
+ * `runtime: 'module'`: the runtime files written into `runtime/` beside the client —
+ * the raw per-needs modules plus the generated factory, each under the standard banner.
+ */
+export function emitRuntimeFiles(
+ model: ApiModel,
+ options: EmitOptions
+): Array<{ name: string; content: string }> {
+ if (options.runtime !== 'module') return [];
+ return runtimeModuleFiles(runtimeNeeds(model, options), options.importExt ?? 'js').map(
+ ({ name, content }) => ({ name, content: `${HEADER}\n\n${content.trim()}\n` })
+ );
+}
+
+/**
+ * The module-mode replacement for the embedded block: the entry imports what its own
+ * code references and re-exports the factory's public surface (the same names the
+ * inline embed leaves in module scope).
+ */
+function runtimeImports(options: EmitOptions, ctx: EmitContext, hasOps: boolean): string {
+ const ext = options.importExt ?? 'js';
+ const typeNames = [
+ 'OperationDescriptor',
+ ...(options.setup ? ['ClientConfig', 'Middleware'] : []),
+ ...(ctx.errorMode === 'result' && hasOps ? ['Result'] : []),
+ ].sort();
+ return [
+ `import { createClient${options.setup ? ', mergeSetup' : ''} } from './runtime/factory.${ext}';`,
+ `import type { ${typeNames.join(', ')} } from './runtime/types.${ext}';`,
+ `export * from './runtime/factory.${ext}';`,
+ ].join('\n');
+}
+
/**
* `split` mode: the same client with the schema types + type guards carved out into a
* sibling `.schemas.ts`. The entry file re-exports the schemas module
@@ -54,12 +103,11 @@ function emitClient(
options: EmitOptions,
splitStem?: string
): { entry: string; schemas?: string } {
- const embed = options.runtime !== 'package';
const ops = allOperations(model.services);
const idents = packageIdents(model);
// Resolved (and VERIFIED) up front: an explicit rule that doesn't fit throws here,
// before any statement is built — one aggregated error for the whole model.
- const pagination = resolveModelPagination(model, options.pagination);
+ const pagination = options.pagination ?? new Map();
const ctx: EmitContext = {
argsStyle: options.argsStyle ?? 'grouped',
errorMode: options.errorMode ?? 'throw',
@@ -68,9 +116,6 @@ function emitClient(
schemas: model.schemas,
pagination,
};
- const hasSse = ops.some(isSseOp);
- const hasRegular = ops.some((op) => !isSseOp(op));
-
const wiring =
ops.length > 0
? [
@@ -83,17 +128,10 @@ function emitClient(
'export const OPERATIONS = {} as const satisfies Record;',
];
- const runtimeSection = embed
- ? assembleInlineRuntime({
- multipart: ops.some((op) => op.requestBody && isTypedMultipart(op.requestBody)),
- // Auth sugar needs schemes; `resolveAuth` fires when a descriptor carries
- // `security` — a valid spec implies the former, but embed on either.
- auth: model.securitySchemes.length > 0 || ops.some((op) => op.security.length > 0),
- sse: hasSse,
- setup: !!options.setup,
- paginate: pagination.size > 0,
- })
- : importLine(options, ctx, { hasRegular });
+ const runtimeSection =
+ options.runtime === 'module'
+ ? runtimeImports(options, ctx, ops.length > 0)
+ : assembleInlineRuntime(runtimeNeeds(model, options));
const schemaSection = [
renderTypeAliases(model.schemas, ctx.dateType),
renderTypeGuards(model.schemas),
@@ -104,25 +142,23 @@ function emitClient(
.filter((section) => section.length > 0)
.join('\n\n');
const sugar = sugarSection(ops, idents);
- // Embed mode exports its whole public surface in place; only the package arm re-exports.
- const reexports = embed ? '' : reexportLines(ctx, hasSse);
// Layout puts the reader's OWN API first (types → aliases → Ops → OPERATIONS) and the
- // machinery after it. In embed mode the runtime block sits between the descriptors and
- // the `client` initializer — after it for readability, before `client` so every
- // declaration the module-init call chain touches (hoisted functions AND any future
- // top-level const) is already evaluated; in package mode the import line leads.
+ // machinery after it. The runtime block sits between the descriptors and the `client`
+ // initializer — after it for readability, before `client` so every declaration the
+ // module-init call chain touches (hoisted functions AND any future top-level const)
+ // is already evaluated.
+ const moduleMode = options.runtime === 'module';
if (splitStem === undefined) {
return {
entry: banner([
HEADER,
renderTitleComment(model),
- ...(embed ? [] : [runtimeSection]),
+ ...(moduleMode ? [runtimeSection] : []),
[schemaSection, bodySection].filter((section) => section.length > 0).join('\n\n'),
- ...(embed ? [runtimeSection] : []),
+ ...(moduleMode ? [] : [runtimeSection]),
clientSection(options, ctx, model),
sugar,
- reexports,
]),
};
}
@@ -132,15 +168,14 @@ function emitClient(
entry: banner([
HEADER,
renderTitleComment(model),
+ ...(moduleMode ? [runtimeSection] : []),
hasSchemas
? schemaLinks(model, ctx, `./${splitStem}.schemas.${options.importExt ?? 'js'}`)
: '',
- ...(embed ? [] : [runtimeSection]),
bodySection,
- ...(embed ? [runtimeSection] : []),
+ ...(moduleMode ? [] : [runtimeSection]),
clientSection(options, ctx, model),
sugar,
- reexports,
]),
schemas: hasSchemas ? banner([HEADER, renderTitleComment(model), schemaSection]) : undefined,
};
@@ -158,20 +193,6 @@ function schemaLinks(model: ApiModel, ctx: EmitContext, specifier: string): stri
return `${importLine}export * from '${specifier}';`;
}
-/** The single import from the runtime package — only names the file actually references. */
-function importLine(options: EmitOptions, ctx: EmitContext, refs: { hasRegular: boolean }): string {
- const values = ['createClient', ...(options.setup ? ['mergeSetup'] : [])];
- const types = [
- ...(options.setup ? ['ClientConfig', 'Middleware'] : []),
- 'OperationDescriptor',
- // `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'] : []),
- ].sort();
- const names = [...values, ...types.map((t) => `type ${t}`)].join(', ');
- return `import { ${names} } from '${PACKAGE_SPECIFIER}';`;
-}
-
/** The (optional) baked setup + the default `client` instance. */
function clientSection(options: EmitOptions, ctx: EmitContext, model: ApiModel): string {
const serverUrl = options.serverUrl ?? model.serverUrl;
@@ -227,21 +248,3 @@ function sugarSection(ops: OperationModel[], idents: Map): strin
lines.push(`export const { ${names} } = client;`);
return lines.join('\n');
}
-
-/** 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'] : []),
- ...(hasSse ? ['ServerSentEvent', 'SseOptions'] : []),
- ].sort();
- return (
- // `createClient` is re-exported so package-mode consumers can build additional
- // instances from the generated module alone — symmetric with inline output.
- `export { ApiError, createClient, defaultRetryOn, TimeoutError } from '${PACKAGE_SPECIFIER}';\n` +
- `export type { ${types.join(', ')} } from '${PACKAGE_SPECIFIER}';`
- );
-}
diff --git a/packages/client-generator/src/emitters/descriptor.ts b/packages/client-generator/src/generators/typescript/descriptor.ts
similarity index 77%
rename from packages/client-generator/src/emitters/descriptor.ts
rename to packages/client-generator/src/generators/typescript/descriptor.ts
index 85208cb4f0..681f3d81cf 100644
--- a/packages/client-generator/src/emitters/descriptor.ts
+++ b/packages/client-generator/src/generators/typescript/descriptor.ts
@@ -6,22 +6,21 @@
import {
allOperations,
type ApiModel,
+ type ArgsStyle,
+ type DateType,
+ type ModelPagination,
type NamedSchemaModel,
type OperationModel,
+ securityRequirements,
type SecuritySchemeModel,
-} from '../intermediate-representation/model.js';
-import type { SecuritySpec } from '../runtime/types.js';
-import { uniqueIdent } from './identifier.js';
-import { isTypedMultipart } from './operation-types.js';
-import type { ArgsStyle } from './operations.js';
-import type { ModelPagination } from './pagination.js';
-import { flatInputShape, responseText } from './render-client.js';
-import { WIRING_NAMES } from './reserved-names.js';
-import { responseHeaderSpecs } from './response-headers.js';
-import { isSseOp, sseDataKind } from './sse.js';
-import { codeLiteral } from './ts-literal.js';
-import { tsJsdoc } from './ts-type.js';
-import type { DateType } from './types.js';
+ WIRING_NAMES,
+} from '@redocly/client-generator';
+import { codeLiteral, uniqueIdent } from '@redocly/client-generator/printers/typescript';
+
+import { isTypedMultipart } from './operation-types.ts';
+import { flatInputShape, responseText } from './render-client.ts';
+import { responseHeaderSpecs } from './response-headers.ts';
+import { tsJsdoc } from './ts-type.ts';
/**
* Operation-name → emitted-identifier plan. The full reserved set (wiring + imported
@@ -54,22 +53,8 @@ function descriptorValue(
...(p.allowReserved !== undefined ? { allowReserved: p.allowReserved } : {}),
})
);
- const toSpecs = (key: string): SecuritySpec[] => {
- const s = schemes.find((scheme) => scheme.key === key);
- if (!s) return [];
- if (s.kind === 'bearer' || s.kind === 'basic') return [{ scheme: key, kind: s.kind }];
- if (s.kind === 'apiKeyHeader') {
- return [{ scheme: key, kind: 'apiKey', name: s.headerName, in: 'header' }];
- }
- if (s.kind === 'apiKeyQuery') {
- return [{ scheme: key, kind: 'apiKey', name: s.paramName, in: 'query' }];
- }
- return [{ scheme: key, kind: 'apiKey', name: s.cookieName, in: 'cookie' }];
- };
- const security = op.security
- .map((alternative) => alternative.flatMap(toSpecs))
- .filter((alternative) => alternative.length > 0);
- const sse = isSseOp(op);
+ const security = securityRequirements(op, { securitySchemes: schemes });
+ const sse = op.sse !== undefined;
const responseKind = sse ? 'sse' : responseText(op.successResponses, dateType).kind;
const responseHeaders = responseHeaderSpecs(op.successResponseHeaders, schemas);
return {
@@ -91,7 +76,7 @@ function descriptorValue(
}
: {}),
...(responseKind !== 'json' ? { responseKind } : {}),
- ...(sse ? { sseDataKind: sseDataKind(op) } : {}),
+ ...(op.sse === undefined ? {} : { sseDataKind: op.sse.dataKind }),
...(security.length > 0 ? { security } : {}),
...(responseHeaders === undefined ? {} : { responseHeaders }),
// The resolved spec is already normalized with stable key order (see pagination.ts).
diff --git a/packages/client-generator/src/generators/typescript/index.ts b/packages/client-generator/src/generators/typescript/index.ts
index e690ee9ac6..2c19237c30 100644
--- a/packages/client-generator/src/generators/typescript/index.ts
+++ b/packages/client-generator/src/generators/typescript/index.ts
@@ -1,11 +1,14 @@
+import {
+ type CodeSample,
+ type Generator,
+ type OperationModel,
+ renderReferencePage,
+ type SampleContext,
+} from '@redocly/client-generator';
import { join } from 'node:path';
-import { renderReferencePage } from '../../authoring/reference-page.js';
-import { emitClientSingleFile, emitClientSplit } from '../../emitters/client-assembly.js';
-import { packageIdents } from '../../emitters/descriptor.js';
-import type { OperationModel } from '../../intermediate-representation/model.js';
-import { anchor } from '../anchor.js';
-import type { CodeSample, Generator, SampleContext } from '../types.js';
+import { emitClientSingleFile, emitClientSplit, emitRuntimeFiles } from './client-assembly.ts';
+import { packageIdents } from './descriptor.ts';
/**
* The default generator: the full typed client (model types + runtime + endpoints).
@@ -16,18 +19,24 @@ import type { CodeSample, Generator, SampleContext } from '../types.js';
* const-objects, type guards; skipped when the document declares no schemas) and
* `.ts` (everything else, which `export *`s the schemas module).
*/
-export const typescriptGenerator: Generator = ({ model, outputPath, outputMode, emit }) => {
+export const typescriptGenerator: Generator = ({ model, output, outputMode, emit }) => {
+ // `runtime: 'module'` adds the per-needs runtime files beside the client.
+ const runtime = emitRuntimeFiles(model, emit).map(({ name, content }) => ({
+ path: join(output.dir, 'runtime', name),
+ content,
+ }));
if (outputMode === 'split') {
- const { dir, stem } = anchor(outputPath);
+ const { dir, stem } = output;
const { entry, schemas } = emitClientSplit(model, emit, stem);
return [
...(schemas === undefined
? []
: [{ path: join(dir, `${stem}.schemas.ts`), content: schemas }]),
- { path: outputPath, content: entry },
+ { path: output.path, content: entry },
+ ...runtime,
];
}
- return [{ path: outputPath, content: emitClientSingleFile(model, emit) }];
+ return [{ path: output.path, content: emitClientSingleFile(model, emit) }, ...runtime];
};
/**
@@ -35,9 +44,9 @@ export const typescriptGenerator: Generator = ({ model, outputPath, outputMode,
* `typescriptSample` below, so the page shows the calling convention this run generated —
* `argsStyle` included.
*/
-export const typescriptDocs: Generator = ({ model, outputPath, emit }) => [
+export const typescriptDocs: Generator = ({ model, output, emit, pagination }) => [
{
- path: outputPath.replace(/\.[^.\\/]+$/, '.typescript.md'),
+ path: output.path.replace(/\.[^.\\/]+$/, '.typescript.md'),
content: renderReferencePage(model, {
title: `${model.title} TypeScript client reference`,
frontmatter: emit.docsFrontmatter === true,
@@ -47,8 +56,8 @@ export const typescriptDocs: Generator = ({ model, outputPath, emit }) => [
fence: 'typescript',
requires: 'The client has no dependencies.',
},
- sample: (op) => typescriptSample(op, { model, emit, outputPath }),
- pagination: emit.pagination,
+ sample: (op) => typescriptSample(op, { model, emit, outputPath: output.path }),
+ paginated: new Set(pagination?.keys() ?? []),
}),
},
];
diff --git a/packages/client-generator/src/emitters/inline-runtime.ts b/packages/client-generator/src/generators/typescript/inline-runtime.ts
similarity index 54%
rename from packages/client-generator/src/emitters/inline-runtime.ts
rename to packages/client-generator/src/generators/typescript/inline-runtime.ts
index efc7e87c10..be85a3aa84 100644
--- a/packages/client-generator/src/emitters/inline-runtime.ts
+++ b/packages/client-generator/src/generators/typescript/inline-runtime.ts
@@ -5,7 +5,11 @@
// capabilities this API needs. Pure string concatenation: no `typescript` at
// generate time.
-import { RUNTIME_SOURCES_STRIPPED, type RuntimeModuleName } from './runtime-sources.js';
+import {
+ RUNTIME_SOURCES,
+ RUNTIME_SOURCES_STRIPPED,
+ type RuntimeModuleName,
+} from '@redocly/client-generator/runtime-sources';
/** Which optional runtime capabilities the generated client must embed. */
export type InlineRuntimeNeeds = {
@@ -19,10 +23,10 @@ export type InlineRuntimeNeeds = {
const HEADER =
"// ─── Embedded runtime (@redocly/client-generator, assembled per this API's needs) ───";
-/** The embedded runtime source block: stripped modules in dependency order + the factory. */
-export function assembleInlineRuntime(needs: InlineRuntimeNeeds): string {
- // Import-graph topological order; the optional capability modules slot in where the
- // package barrel would import them (core never imports them statically).
+/** The per-needs module set, in import-graph topological order (both modes share it). */
+function runtimeModules(needs: InlineRuntimeNeeds): RuntimeModuleName[] {
+ // The optional capability modules slot in where the runtime barrel would import
+ // them (core never imports them statically).
const modules: RuntimeModuleName[] = ['types.ts', 'errors.ts', 'url.ts', 'parse.ts', 'retry.ts'];
if (needs.multipart) modules.push('multipart.ts');
if (needs.auth) modules.push('auth.ts');
@@ -33,16 +37,66 @@ export function assembleInlineRuntime(needs: InlineRuntimeNeeds): string {
modules.push('send.ts');
if (needs.sse) modules.push('sse.ts');
modules.push('create-client.ts');
+ return modules;
+}
+
+/** The embedded runtime source block: stripped modules in dependency order + the factory. */
+export function assembleInlineRuntime(needs: InlineRuntimeNeeds): string {
return [
HEADER,
- ...modules.map((name) => RUNTIME_SOURCES_STRIPPED[name]),
+ ...runtimeModules(needs).map((name) => RUNTIME_SOURCES_STRIPPED[name]),
clientFactory(needs),
].join('\n\n');
}
-/** The cli engine (`runCli` + types) stripped for embedding into `.cli.ts`. */
-export function embedCliRuntime(): string {
- return RUNTIME_SOURCES_STRIPPED['cli.ts'];
+/**
+ * The runtime as real files (`runtime: 'module'`): the same per-needs modules, RAW —
+ * imports intact, exactly as authored — plus `factory.ts`, the per-needs `createClient`
+ * wiring as a module importing what it references from its siblings. `importExt`
+ * rewrites the intra-runtime specifiers for consumers that resolve them literally.
+ */
+export function runtimeModuleFiles(
+ needs: InlineRuntimeNeeds,
+ importExt: 'js' | 'ts' = 'js'
+): Array<{ name: string; content: string }> {
+ const files = [
+ ...runtimeModules(needs).map((name) => ({ name, content: RUNTIME_SOURCES[name] })),
+ { name: 'factory.ts', content: moduleFactory(needs) },
+ ];
+ if (importExt === 'js') return files;
+ return files.map(({ name, content }) => ({
+ name,
+ content: content.replace(/(from '\.\/[a-z-]+)\.js'/g, "$1.ts'"),
+ }));
+}
+
+/** `factory.ts`: the sibling-module equivalent of the inline factory block. */
+function moduleFactory(needs: InlineRuntimeNeeds): string {
+ const imports = [
+ "import { createClientCore } from './create-client.js';",
+ ...(needs.multipart ? ["import { toFormData } from './multipart.js';"] : []),
+ ...(needs.auth ? ["import { resolveAuth } from './auth.js';"] : []),
+ ...(needs.paginate
+ ? ["import { items, itemsByLink, pages, pagesByLink } from './paginate.js';"]
+ : []),
+ ...(needs.sse ? ["import { sse } from './sse.js';"] : []),
+ `import type {
+ Client,
+ ClientConfig,
+ OperationContext,
+ OperationDescriptor,
+ OpsShape,
+} from './types.js';`,
+ ];
+ // The client entry re-exports this module, so the factory carries the same public
+ // surface the inline embed leaves in module scope (the kept-export set).
+ const reexports = [
+ "export { ApiError, TimeoutError } from './errors.js';",
+ "export { defaultRetryOn } from './retry.js';",
+ ...(needs.setup ? ["export { mergeSetup } from './setup.js';"] : []),
+ "export type * from './types.js';",
+ ];
+ return [imports.join('\n'), clientFactory(needs), reexports.join('\n')].join('\n\n');
}
// The embedded equivalent of the package barrel's `createClient`: `createClientCore`
diff --git a/packages/client-generator/src/emitters/operation-signature.ts b/packages/client-generator/src/generators/typescript/operation-signature.ts
similarity index 93%
rename from packages/client-generator/src/emitters/operation-signature.ts
rename to packages/client-generator/src/generators/typescript/operation-signature.ts
index fe9224a95e..25c2c46f9e 100644
--- a/packages/client-generator/src/emitters/operation-signature.ts
+++ b/packages/client-generator/src/generators/typescript/operation-signature.ts
@@ -2,8 +2,8 @@
// operation's input type) and the wrapper generators (which forward it) read slot presence
// and `Variables` naming from this one source, so a call and its type cannot drift.
-import type { OperationModel, ParamModel } from '../intermediate-representation/model.js';
-import { pascalCase } from './support.js';
+import type { OperationModel, ParamModel } from '@redocly/client-generator';
+import { pascalCase } from '@redocly/client-generator/printers/typescript';
export type OperationSignature = {
/** Slot presence — which input layers the operation has. */
diff --git a/packages/client-generator/src/emitters/operation-types.ts b/packages/client-generator/src/generators/typescript/operation-types.ts
similarity index 87%
rename from packages/client-generator/src/emitters/operation-types.ts
rename to packages/client-generator/src/generators/typescript/operation-types.ts
index 9c670d038f..5cbc7a0ec1 100644
--- a/packages/client-generator/src/emitters/operation-types.ts
+++ b/packages/client-generator/src/generators/typescript/operation-types.ts
@@ -1,6 +1,6 @@
// Shared operation-shape predicates.
-import type { RequestBodyModel } from '../intermediate-representation/model.js';
+import type { RequestBodyModel } from '@redocly/client-generator';
/**
* A multipart body whose schema is a concrete object — the case worth typing. Such a body
diff --git a/packages/client-generator/src/emitters/render-client.ts b/packages/client-generator/src/generators/typescript/render-client.ts
similarity index 92%
rename from packages/client-generator/src/emitters/render-client.ts
rename to packages/client-generator/src/generators/typescript/render-client.ts
index aeddc854c6..623eb30b28 100644
--- a/packages/client-generator/src/emitters/render-client.ts
+++ b/packages/client-generator/src/generators/typescript/render-client.ts
@@ -1,26 +1,45 @@
-// The operation-level renderers behind the client assembly: the `Ops` type map,
-// the `*` alias cluster, the flat call sugar, and the split layout's schema
-// import list — all derived from the IR and the shared `EmitContext`.
-
import {
allOperations,
type ApiModel,
+ type ArgsStyle,
+ type DateType,
+ type ErrorMode,
+ type ModelPagination,
type NamedSchemaModel,
type OperationModel,
type ParamModel,
type RequestBodyModel,
type ResponseBodyModel,
type SchemaModel,
-} from '../intermediate-representation/model.js';
-import { safeIdent } from './identifier.js';
-import { operationSignature, templatePathParams } from './operation-signature.js';
-import { isTypedMultipart } from './operation-types.js';
-import type { EmitContext } from './operations.js';
-import { responseHeadersTypeText } from './response-headers.js';
-import { eventSchema, isSseOp } from './sse.js';
-import { pascalCase } from './support.js';
-import { tsJsdoc, tsType } from './ts-type.js';
-import type { DateType } from './types.js';
+} from '@redocly/client-generator';
+// The operation-level renderers behind the client assembly: the `Ops` type map,
+// the `*` alias cluster, the flat call sugar, and the split layout's schema
+// import list — all derived from the IR and the shared `EmitContext`.
+import { pascalCase, safeIdent } from '@redocly/client-generator/printers/typescript';
+
+import { operationSignature, templatePathParams } from './operation-signature.ts';
+import { isTypedMultipart } from './operation-types.ts';
+import { responseHeadersTypeText } from './response-headers.ts';
+import { tsJsdoc, tsType } from './ts-type.ts';
+
+/**
+ * The emit configuration every operation shares. Bundling it into one value keeps
+ * it out of the positional parameter lists of the operation emitters (which would
+ * otherwise thread the same arguments through every layer, inviting transposition
+ * bugs). Per-call structural data (response type, ordered path params, …) stays an
+ * explicit argument; only this cross-cutting config travels as `ctx`.
+ */
+export type EmitContext = {
+ argsStyle: ArgsStyle;
+ errorMode: ErrorMode;
+ dateType: DateType;
+ /** Names of every exported schema, used for `*` alias collision suppression. */
+ schemaNames: Set;
+ /** Named schemas — used to resolve `$ref` / `allOf` wrappers on response-header types. */
+ schemas?: readonly NamedSchemaModel[];
+ /** Resolved auto-pagination per operation name (absent ⇒ nothing paginates). */
+ pagination?: ModelPagination;
+};
const INDENT = ' ';
@@ -102,7 +121,7 @@ export function errorTypeTexts(
/** The TS type of a streamed event payload (`string` when no schema is declared). */
function sseEventText(op: OperationModel, dateType: DateType, indent = ''): string {
- const schema = eventSchema(op);
+ const schema = op.sse?.eventSchema;
return schema ? tsType(schema, dateType, indent) : 'string';
}
@@ -351,7 +370,7 @@ export function renderOpsType(
const name = pascalCase(op.name);
const inner = INDENT + INDENT;
const args = variablesTypeText(op, name, ctx, inner);
- const sse = isSseOp(op);
+ const sse = op.sse !== undefined;
const result = sse
? sseEventText(op, ctx.dateType, inner)
: ctx.errorMode === 'result'
@@ -394,7 +413,7 @@ export function renderOpsType(
export function renderAliases(op: OperationModel, ctx: EmitContext): string {
const { dateType, schemaNames } = ctx;
const name = pascalCase(op.name);
- const sse = isSseOp(op);
+ const sse = op.sse !== undefined;
const { hasInputs } = operationSignature(op);
const blocks: string[] = [];
diff --git a/packages/client-generator/src/emitters/response-headers.ts b/packages/client-generator/src/generators/typescript/response-headers.ts
similarity index 85%
rename from packages/client-generator/src/emitters/response-headers.ts
rename to packages/client-generator/src/generators/typescript/response-headers.ts
index c6d7e52618..bd38728eed 100644
--- a/packages/client-generator/src/emitters/response-headers.ts
+++ b/packages/client-generator/src/generators/typescript/response-headers.ts
@@ -1,15 +1,14 @@
// Success-response header helpers: descriptor parse hints + Ops / alias type text
// for throw-mode `{ envelope: true }`.
-import { headerCoerceType } from '../authoring/index.js';
-import type {
- NamedSchemaModel,
- 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 {
+ headerCoerceType,
+ type NamedSchemaModel,
+ type ResponseHeaderModel,
+ type ResponseHeaderSpec,
+ type SchemaModel,
+} from '@redocly/client-generator';
+import { headerPropertyKey, uniqueIdent } from '@redocly/client-generator/printers/typescript';
const INDENT = ' ';
diff --git a/packages/client-generator/src/runtime/__tests__/auth.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/auth.test.ts
similarity index 100%
rename from packages/client-generator/src/runtime/__tests__/auth.test.ts
rename to packages/client-generator/src/generators/typescript/runtime/__tests__/auth.test.ts
diff --git a/packages/client-generator/src/runtime/__tests__/create-client.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/create-client.test.ts
similarity index 100%
rename from packages/client-generator/src/runtime/__tests__/create-client.test.ts
rename to packages/client-generator/src/generators/typescript/runtime/__tests__/create-client.test.ts
diff --git a/packages/client-generator/src/runtime/__tests__/errors.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/errors.test.ts
similarity index 100%
rename from packages/client-generator/src/runtime/__tests__/errors.test.ts
rename to packages/client-generator/src/generators/typescript/runtime/__tests__/errors.test.ts
diff --git a/packages/client-generator/src/runtime/__tests__/index.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/index.test.ts
similarity index 98%
rename from packages/client-generator/src/runtime/__tests__/index.test.ts
rename to packages/client-generator/src/generators/typescript/runtime/__tests__/index.test.ts
index 40ab8c577f..6eff2d6a02 100644
--- a/packages/client-generator/src/runtime/__tests__/index.test.ts
+++ b/packages/client-generator/src/generators/typescript/runtime/__tests__/index.test.ts
@@ -1,4 +1,4 @@
-import { defineClientSetup, type Middleware } from '../../runtime-contract.js';
+import { defineClientSetup, type Middleware } from '../../../../runtime-contract.js';
import { ApiError, createClient, mergeSetup, type OperationDescriptor } from '../index.js';
const OPS = {
diff --git a/packages/client-generator/src/runtime/__tests__/multipart.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/multipart.test.ts
similarity index 100%
rename from packages/client-generator/src/runtime/__tests__/multipart.test.ts
rename to packages/client-generator/src/generators/typescript/runtime/__tests__/multipart.test.ts
diff --git a/packages/client-generator/src/runtime/__tests__/paginate.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/paginate.test.ts
similarity index 100%
rename from packages/client-generator/src/runtime/__tests__/paginate.test.ts
rename to packages/client-generator/src/generators/typescript/runtime/__tests__/paginate.test.ts
diff --git a/packages/client-generator/src/runtime/__tests__/parse.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/parse.test.ts
similarity index 100%
rename from packages/client-generator/src/runtime/__tests__/parse.test.ts
rename to packages/client-generator/src/generators/typescript/runtime/__tests__/parse.test.ts
diff --git a/packages/client-generator/src/runtime/__tests__/retry.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/retry.test.ts
similarity index 100%
rename from packages/client-generator/src/runtime/__tests__/retry.test.ts
rename to packages/client-generator/src/generators/typescript/runtime/__tests__/retry.test.ts
diff --git a/packages/client-generator/src/runtime/__tests__/send.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/send.test.ts
similarity index 100%
rename from packages/client-generator/src/runtime/__tests__/send.test.ts
rename to packages/client-generator/src/generators/typescript/runtime/__tests__/send.test.ts
diff --git a/packages/client-generator/src/runtime/__tests__/sse.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/sse.test.ts
similarity index 100%
rename from packages/client-generator/src/runtime/__tests__/sse.test.ts
rename to packages/client-generator/src/generators/typescript/runtime/__tests__/sse.test.ts
diff --git a/packages/client-generator/src/runtime/__tests__/types.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/types.test.ts
similarity index 100%
rename from packages/client-generator/src/runtime/__tests__/types.test.ts
rename to packages/client-generator/src/generators/typescript/runtime/__tests__/types.test.ts
diff --git a/packages/client-generator/src/runtime/__tests__/url.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/url.test.ts
similarity index 100%
rename from packages/client-generator/src/runtime/__tests__/url.test.ts
rename to packages/client-generator/src/generators/typescript/runtime/__tests__/url.test.ts
diff --git a/packages/client-generator/src/runtime/auth.ts b/packages/client-generator/src/generators/typescript/runtime/auth.ts
similarity index 100%
rename from packages/client-generator/src/runtime/auth.ts
rename to packages/client-generator/src/generators/typescript/runtime/auth.ts
diff --git a/packages/client-generator/src/runtime/create-client.ts b/packages/client-generator/src/generators/typescript/runtime/create-client.ts
similarity index 100%
rename from packages/client-generator/src/runtime/create-client.ts
rename to packages/client-generator/src/generators/typescript/runtime/create-client.ts
diff --git a/packages/client-generator/src/runtime/errors.ts b/packages/client-generator/src/generators/typescript/runtime/errors.ts
similarity index 100%
rename from packages/client-generator/src/runtime/errors.ts
rename to packages/client-generator/src/generators/typescript/runtime/errors.ts
diff --git a/packages/client-generator/src/runtime/index.ts b/packages/client-generator/src/generators/typescript/runtime/index.ts
similarity index 100%
rename from packages/client-generator/src/runtime/index.ts
rename to packages/client-generator/src/generators/typescript/runtime/index.ts
diff --git a/packages/client-generator/src/runtime/multipart.ts b/packages/client-generator/src/generators/typescript/runtime/multipart.ts
similarity index 100%
rename from packages/client-generator/src/runtime/multipart.ts
rename to packages/client-generator/src/generators/typescript/runtime/multipart.ts
diff --git a/packages/client-generator/src/runtime/paginate.ts b/packages/client-generator/src/generators/typescript/runtime/paginate.ts
similarity index 100%
rename from packages/client-generator/src/runtime/paginate.ts
rename to packages/client-generator/src/generators/typescript/runtime/paginate.ts
diff --git a/packages/client-generator/src/runtime/parse.ts b/packages/client-generator/src/generators/typescript/runtime/parse.ts
similarity index 100%
rename from packages/client-generator/src/runtime/parse.ts
rename to packages/client-generator/src/generators/typescript/runtime/parse.ts
diff --git a/packages/client-generator/src/runtime/retry.ts b/packages/client-generator/src/generators/typescript/runtime/retry.ts
similarity index 100%
rename from packages/client-generator/src/runtime/retry.ts
rename to packages/client-generator/src/generators/typescript/runtime/retry.ts
diff --git a/packages/client-generator/src/runtime/send.ts b/packages/client-generator/src/generators/typescript/runtime/send.ts
similarity index 100%
rename from packages/client-generator/src/runtime/send.ts
rename to packages/client-generator/src/generators/typescript/runtime/send.ts
diff --git a/packages/client-generator/src/runtime/setup.ts b/packages/client-generator/src/generators/typescript/runtime/setup.ts
similarity index 100%
rename from packages/client-generator/src/runtime/setup.ts
rename to packages/client-generator/src/generators/typescript/runtime/setup.ts
diff --git a/packages/client-generator/src/runtime/sse.ts b/packages/client-generator/src/generators/typescript/runtime/sse.ts
similarity index 100%
rename from packages/client-generator/src/runtime/sse.ts
rename to packages/client-generator/src/generators/typescript/runtime/sse.ts
diff --git a/packages/client-generator/src/runtime/types.ts b/packages/client-generator/src/generators/typescript/runtime/types.ts
similarity index 75%
rename from packages/client-generator/src/runtime/types.ts
rename to packages/client-generator/src/generators/typescript/runtime/types.ts
index 5421fe17f9..b7345a0a3b 100644
--- a/packages/client-generator/src/runtime/types.ts
+++ b/packages/client-generator/src/generators/typescript/runtime/types.ts
@@ -6,6 +6,16 @@
* incompatible runtime/generated pair fails the consumer's build (the semver skew guard).
*/
+import type { PaginationSpec } from '../../../pagination.js';
+import type {
+ ApiErrorLike,
+ ResponseHeaderSpec,
+ Middleware,
+ OperationContext,
+ RequestContext,
+ RetryConfig,
+} from '../../../runtime-contract.js';
+
/** How one operation parameter is sent: its location plus OpenAPI query-serialization hints. */
export type ParamSpec = {
name: string;
@@ -20,41 +30,11 @@ export type SecuritySpec =
| { scheme: string; kind: 'bearer' | 'basic' }
| { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' };
-/**
- * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members).
- * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value.
- */
-export type PaginationSpec =
- | {
- style: 'cursor';
- /** The query param the iterator advances with the response's cursor. */
- param: string;
- /** Optional page-size query param (recorded for tooling; never set by the runtime). */
- limitParam?: string;
- /** Pointer to the next cursor in the page. */
- nextCursor: string;
- /** Optional pointer to a boolean "more pages" flag — `false` stops iteration. */
- hasMore?: string;
- /** Pointer to the page's item array. */
- items: string;
- }
- | {
- style: 'offset' | 'page';
- /** The numeric query param the iterator advances. */
- param: string;
- /** Optional page-size query param (recorded for tooling; never set by the runtime). */
- limitParam?: string;
- /** Pointer to the page's item array. */
- items: string;
- }
- | {
- /** RFC 8288: follow the response's `Link` header `rel="next"`; stop when absent. */
- style: 'link';
- /** Optional page-size query param (recorded for tooling; never set by the runtime). */
- limitParam?: string;
- /** Pointer to the page's item array. */
- items: string;
- };
+// The spec this runtime drives is DEFINED at the package level, beside the resolver
+// that produces it (src/pagination.ts); re-exported here so the generated client's
+// type surface is unchanged. The embed splices the definition back in (see
+// scripts/generate-runtime-sources.mjs).
+export type { PaginationSpec } from '../../../pagination.js';
/** The frozen data contract between generated code and the runtime: one operation's wire shape. */
export type OperationDescriptor = {
@@ -84,12 +64,7 @@ export type OperationDescriptor = {
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';
-};
+export type { ResponseHeaderSpec } from '../../../runtime-contract.js';
/** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */
export type QueryValue =
@@ -111,75 +86,18 @@ export type AuthCredentials = {
apiKey?: Record;
};
-/** Backoff shape: 'fixed' = constant delay; 'exponential' = doubling per attempt. */
-export type RetryStrategy = 'fixed' | 'exponential';
-
-/**
- * The operation's identity, exposed to middleware for targeting (`ctx.operation`).
- * Generated clients instantiate the type parameters with the spec's literal unions
- * (`OperationId`/`OperationPath`/`OperationTag`) so a misspelled operation id in a
- * middleware comparison fails to compile; the string defaults keep every
- * spec-independent consumer (`runtime-contract.ts`, the runtime internals) working
- * with the base shape. `tags` stays mutable (`Tag[]`) so setup-contract types
- * (byte-locked to generated output) remain assignable through middleware callbacks.
- */
-export type OperationContext<
- Id extends string = string,
- Path extends string = string,
- Tag extends string = string,
-> = { id: Id; path: Path; tags: Tag[] };
-
-/** The mutable request context threaded through the middleware chain. */
-export type RequestContext = {
- url: string;
- method: string;
- headers: Record;
- body?: unknown;
- operation: Op;
-};
-
-/** The failed attempt handed to a custom `retryOn`: exactly one of `response`/`error` is set. */
-export type RetryContext = {
- attempt: number;
- request: RequestContext;
- response?: Response;
- error?: unknown;
-};
-
-/** Opt-in retry policy; a per-call override merges field-by-field over the config policy. */
-export type RetryConfig = {
- retries?: number;
- retryDelay?: number;
- retryStrategy?: RetryStrategy;
- jitter?: boolean;
- retryOn?: (ctx: RetryContext) => boolean | Promise;
-};
-
-/**
- * Structural stand-in for the runtime's ApiError so this module stays import-free
- * (pure types); the real `ApiError` class is assignable to it.
- */
-export type ApiErrorLike = globalThis.Error & {
- url: string;
- status: number;
- statusText: string;
- body: unknown;
-};
-
-/** One interceptor: any subset of the three hooks. */
-export type Middleware = {
- onRequest?: (ctx: RequestContext) => void | Promise;
- onResponse?: (
- response: Response,
- ctx: RequestContext
- ) => Response | void | Promise;
- /** Throw mode only: may map/replace the error. */
- // `globalThis.Error` so a spec schema named `Error` cannot shadow it in inline mode.
- onError?: (
- error: ApiErrorLike,
- ctx: RequestContext
- ) => globalThis.Error | Promise;
-};
+// The setup contract (ADR-0022): these types are defined at the package level in
+// src/runtime-contract.ts — the layer publishers author `--setup` files against —
+// and re-exported here. The embed splices the definitions back in.
+export type {
+ ApiErrorLike,
+ Middleware,
+ OperationContext,
+ RequestContext,
+ RetryConfig,
+ RetryContext,
+ RetryStrategy,
+} from '../../../runtime-contract.js';
/** Client configuration: transport, defaults, retry policy, middleware, and credentials. */
export type ClientConfig = {
diff --git a/packages/client-generator/src/runtime/url.ts b/packages/client-generator/src/generators/typescript/runtime/url.ts
similarity index 100%
rename from packages/client-generator/src/runtime/url.ts
rename to packages/client-generator/src/generators/typescript/runtime/url.ts
diff --git a/packages/client-generator/src/emitters/ts-type.ts b/packages/client-generator/src/generators/typescript/ts-type.ts
similarity index 94%
rename from packages/client-generator/src/emitters/ts-type.ts
rename to packages/client-generator/src/generators/typescript/ts-type.ts
index 27f410fe2e..7770c872e9 100644
--- a/packages/client-generator/src/emitters/ts-type.ts
+++ b/packages/client-generator/src/generators/typescript/ts-type.ts
@@ -2,16 +2,20 @@
// `typescript` import. Formatting contract: 4-space indent, double-quoted
// literals, compound members parenthesized inside unions/intersections/arrays.
-import type {
- NamedSchemaModel,
- PropertyModel,
- ScalarKind,
- SchemaMetadata,
- SchemaModel,
-} from '../intermediate-representation/model.js';
-import { isIdentifier, safeIdent } from './identifier.js';
-import { escapeJsDoc, jsdocText } from './jsdoc.js';
-import type { DateType } from './types.js';
+import {
+ type DateType,
+ type NamedSchemaModel,
+ type PropertyModel,
+ type ScalarKind,
+ type SchemaMetadata,
+ type SchemaModel,
+} from '@redocly/client-generator';
+import {
+ escapeJsDoc,
+ isIdentifier,
+ jsdocText,
+ safeIdent,
+} from '@redocly/client-generator/printers/typescript';
const INDENT = ' ';
diff --git a/packages/client-generator/src/emitters/type-guards.ts b/packages/client-generator/src/generators/typescript/type-guards.ts
similarity index 98%
rename from packages/client-generator/src/emitters/type-guards.ts
rename to packages/client-generator/src/generators/typescript/type-guards.ts
index 747323dad3..7e677899b9 100644
--- a/packages/client-generator/src/emitters/type-guards.ts
+++ b/packages/client-generator/src/generators/typescript/type-guards.ts
@@ -1,8 +1,4 @@
-import type {
- DiscriminatorModel,
- NamedSchemaModel,
- SchemaModel,
-} from '../intermediate-representation/model.js';
+import type { DiscriminatorModel, NamedSchemaModel, SchemaModel } from '@redocly/client-generator';
/**
* A discriminated union we can emit guards for, found while walking the schema
diff --git a/packages/client-generator/src/emitters/__tests__/zod.test.ts b/packages/client-generator/src/generators/zod/__tests__/schemas.test.ts
similarity index 98%
rename from packages/client-generator/src/emitters/__tests__/zod.test.ts
rename to packages/client-generator/src/generators/zod/__tests__/schemas.test.ts
index 82aecf3898..3908015fbb 100644
--- a/packages/client-generator/src/emitters/__tests__/zod.test.ts
+++ b/packages/client-generator/src/generators/zod/__tests__/schemas.test.ts
@@ -1,6 +1,6 @@
-import type { NamedSchemaModel, SchemaModel } from '../../intermediate-representation/model.js';
-import { renderZodModule, schemaToZodExpression } from '../zod.js';
-import { apiModel, operation, response } from './fixtures.js';
+import { apiModel, operation, response } from '../../../__tests__/fixtures.js';
+import type { NamedSchemaModel, SchemaModel } from '../../../intermediate-representation/model.js';
+import { renderZodModule, schemaToZodExpression } from '../schemas.js';
/** Print a single expression by wrapping it in a throwaway const. */
function expr(schema: SchemaModel): string {
diff --git a/packages/client-generator/src/generators/zod/index.ts b/packages/client-generator/src/generators/zod/index.ts
index 981dba25bb..a25fc39500 100644
--- a/packages/client-generator/src/generators/zod/index.ts
+++ b/packages/client-generator/src/generators/zod/index.ts
@@ -1,9 +1,7 @@
+import type { Generator } from '@redocly/client-generator';
import { join } from 'node:path';
-import { HEADER } from '../../emitters/emit-options.js';
-import { renderZodModule } from '../../emitters/zod.js';
-import { anchor } from '../anchor.js';
-import type { Generator } from '../types.js';
+import { renderZodModule } from './schemas.ts';
/**
* The zod generator: a standalone `.zod.ts` module of Zod schemas (one
@@ -16,9 +14,9 @@ import type { Generator } from '../types.js';
* how the sdk partitions its files. Emits nothing when the model has neither
* named schemas nor JSON operation bodies.
*/
-export const zodGenerator: Generator = ({ model, outputPath }) => {
+export const zodGenerator: Generator = ({ model, output, banner }) => {
const content = renderZodModule(model);
if (content === '') return [];
- const { dir, stem } = anchor(outputPath);
- return [{ path: join(dir, `${stem}.zod.ts`), content: `${HEADER}\n\n${content}` }];
+ const header = banner.map((line) => `// ${line}`).join('\n');
+ return [{ path: join(output.dir, `${output.stem}.zod.ts`), content: `${header}\n\n${content}` }];
};
diff --git a/packages/client-generator/src/emitters/zod.ts b/packages/client-generator/src/generators/zod/schemas.ts
similarity index 98%
rename from packages/client-generator/src/emitters/zod.ts
rename to packages/client-generator/src/generators/zod/schemas.ts
index ced0f512e4..3f8a5563cb 100644
--- a/packages/client-generator/src/emitters/zod.ts
+++ b/packages/client-generator/src/generators/zod/schemas.ts
@@ -16,11 +16,8 @@ import {
type ScalarKind,
type SchemaMetadata,
type SchemaModel,
-} from '../intermediate-representation/model.js';
-import { safeIdent } from './identifier.js';
-import { isSseOp } from './sse.js';
-import { pascalCase } from './support.js';
-import { codeLiteral } from './ts-literal.js';
+} from '@redocly/client-generator';
+import { codeLiteral, pascalCase, safeIdent } from '@redocly/client-generator/printers/typescript';
const INDENT = ' ';
@@ -221,7 +218,7 @@ type OperationSchemaEntry = { name: string; request?: string; response?: string
function operationSchemaEntries(model: ApiModel, byName: SchemaByName): OperationSchemaEntry[] {
const entries: OperationSchemaEntry[] = [];
for (const op of allOperations(model.services)) {
- if (isSseOp(op)) continue;
+ if (op.sse !== undefined) continue;
const requestBody = op.requestBody;
const request =
requestBody && requestBody.contentType.toLowerCase().includes('json')
diff --git a/packages/client-generator/src/index.ts b/packages/client-generator/src/index.ts
index caf6f35d65..d20a72b8d3 100644
--- a/packages/client-generator/src/index.ts
+++ b/packages/client-generator/src/index.ts
@@ -1,10 +1,10 @@
-// The package ROOT entry — what package-mode clients load at app runtime, so its static
-// import graph stays runtime-only (no `typescript`, no `@redocly/openapi-core`, no Node
-// builtins; guarded by entry-weight.test.ts). The generation stack lives behind the dynamic
-// import inside `generateClient` and the `@redocly/client-generator/generate` entry.
+// The package ROOT entry — the authoring surface: the language-neutral toolkit, the
+// plugin API, the user-facing config types, and the setup contract. Nothing imports
+// this entry at app runtime — generated clients embed their runtime (ADR-0022) — and
+// the TypeScript-emitting stack lives behind the dynamic import inside `generateClient`
+// and the `@redocly/client-generator/generate` entry.
-// The language-neutral generator-authoring toolkit — pure functions over the IR,
-// safe on this runtime-only entry (no typescript, no openapi-core, no builtins).
+// The language-neutral generator-authoring toolkit — pure functions over the IR.
export * from './authoring/index.js';
export { NotSupportedError } from './errors.js';
export { defineClientSetup } from './runtime-contract.js';
@@ -18,51 +18,30 @@ export type {
RetryContext,
RetryStrategy,
} from './runtime-contract.js';
-// The app-facing client runtime (package-mode clients import these from the package root).
-// The setup-contract names above (Middleware, OperationContext, RequestContext, RetryConfig,
-// RetryContext, RetryStrategy) are re-exports of the same runtime types — one definition,
-// two entry points; the rest of the runtime's type surface is re-exported here.
-export {
- ApiError,
- createClient,
- defaultRetryOn,
- mergeSetup,
- TimeoutError,
-} from './runtime/index.js';
-export type {
- ApiErrorLike,
- AuthCredentials,
- Client,
- ClientConfig,
- ClientCore,
- Envelope,
- EnvelopeResult,
- OperationDescriptor,
- OperationMethodIdentity,
- OpsShape,
- ParamSpec,
- ParseAs,
- QueryValue,
- RequestOptions,
- Result,
- SecuritySpec,
- ServerSentEvent,
- SseOptions,
- TokenProvider,
-} from './runtime/index.js';
-// The generated-CLI engine (package-mode cli files import it from the package root).
-export { invokedName, runCli } from './runtime/cli.js';
+// Descriptor wire shapes the generators emit and every runtime implements.
+export type { ResponseHeaderSpec } from './runtime-contract.js';
+export type { ModelPagination, PaginationSpec } from './pagination.js';
+export { resolveSchemaPointer } from './pagination.js';
+// Names the generated sdk wiring reserves — the typescript descriptor keeps schema
+// identifiers clear of them.
+export { WIRING_NAMES } from './reserved-names.js';
+// The generated-CLI authoring contract — the command/wiring shapes a wrapper around a
+// generated or composed CLI is written against, plus the two casing helpers CLI-flavored
+// generators share; the engine itself (`runCli`) is embedded in, and re-exported by,
+// every generated cli module.
+export { constantCase, groupSlug } from './cli-contract.js';
export type {
CliAuthScheme,
CliCommand,
+ CliFlag,
CliGlobals,
CliWiring,
CommandContext,
CommandSource,
CustomCommand,
-} from './runtime/cli.js';
+} from './cli-contract.js';
// The user-facing pagination rule shapes (`Config.pagination` / `x-redoclyPagination`).
-export type { PaginationConfig, PaginationRule, PaginationStyle } from './emitters/pagination.js';
+export type { PaginationConfig, PaginationRule, PaginationStyle } from './pagination.js';
export type {
GenerateClientConfig,
GenerateClientOptions,
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 e5f4b65df3..981c1d467c 100644
--- a/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts
+++ b/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts
@@ -1,8 +1,8 @@
import { logger, type Oas3Definition, type Oas3Schema } from '@redocly/openapi-core';
import { NotSupportedError } from '../../errors.js';
-import { buildApiModel } from '../build.js';
-import type { OperationModel, SchemaModel } from '../model.js';
+import { buildApiModel, sseFromResponses } from '../build.js';
+import type { OperationModel, ResponseBodyModel, SchemaModel } from '../model.js';
/**
* Build a minimal Oas3Definition wrapper so each test only declares the part it
@@ -2195,3 +2195,93 @@ describe('extractMetadata — example/default', () => {
expect(got.metadata?.example).toBeUndefined();
});
});
+
+/** A success-response list whose entry streams `text/event-stream`. */
+function sseResponses(response: Partial): ResponseBodyModel[] {
+ return [
+ { contentType: 'text/event-stream', schema: { kind: 'unknown' }, ...response, status: 200 },
+ ];
+}
+
+describe('sseFromResponses — detection', () => {
+ it('is present for a success response with the text/event-stream content type', () => {
+ expect(sseFromResponses(sseResponses({}))).toBeDefined();
+ });
+
+ it('matches with parameters and is case-insensitive', () => {
+ expect(
+ sseFromResponses(sseResponses({ contentType: 'text/event-stream; charset=utf-8' }))
+ ).toBeDefined();
+ expect(sseFromResponses(sseResponses({ contentType: 'Text/Event-Stream' }))).toBeDefined();
+ });
+
+ it('is undefined for a plain JSON operation and for no responses at all', () => {
+ expect(
+ sseFromResponses([
+ { contentType: 'application/json', schema: { kind: 'ref', name: 'Pet' }, status: 200 },
+ ])
+ ).toBeUndefined();
+ expect(sseFromResponses([])).toBeUndefined();
+ });
+});
+
+describe('sseFromResponses — eventSchema (drives the streamed payload type)', () => {
+ it('uses the per-item schema when present', () => {
+ expect(
+ sseFromResponses(sseResponses({ itemSchema: { kind: 'ref', name: 'Message' } }))?.eventSchema
+ ).toEqual({ kind: 'ref', name: 'Message' });
+ });
+
+ it('falls back to the response schema when it is meaningful', () => {
+ expect(
+ sseFromResponses(sseResponses({ schema: { kind: 'ref', name: 'Token' } }))?.eventSchema
+ ).toEqual({ kind: 'ref', name: 'Token' });
+ });
+
+ it('ignores a typeless `itemSchema` and falls back to the response schema', () => {
+ expect(
+ sseFromResponses(
+ sseResponses({ itemSchema: { kind: 'unknown' }, schema: { kind: 'ref', name: 'Token' } })
+ )?.eventSchema
+ ).toEqual({ kind: 'ref', name: 'Token' });
+ });
+
+ it('is undefined when no schema is declared (payload types as `string`)', () => {
+ expect(sseFromResponses(sseResponses({}))?.eventSchema).toBeUndefined();
+ });
+});
+
+describe('sseFromResponses — dataKind', () => {
+ it("is 'json' for object/ref/array/record/union/intersection event types", () => {
+ const json: SchemaModel[] = [
+ { kind: 'object', properties: [] },
+ { kind: 'ref', name: 'Message' },
+ { kind: 'array', items: { kind: 'scalar', scalar: 'string' } },
+ { kind: 'record', value: { kind: 'scalar', scalar: 'string' } },
+ { kind: 'union', members: [{ kind: 'ref', name: 'A' }] },
+ { kind: 'intersection', members: [{ kind: 'ref', name: 'A' }] },
+ ];
+ for (const itemSchema of json) {
+ expect(sseFromResponses(sseResponses({ itemSchema }))?.dataKind).toBe('json');
+ }
+ });
+
+ it("is 'text' for the string fallback and for a typeless `itemSchema`", () => {
+ expect(sseFromResponses(sseResponses({}))?.dataKind).toBe('text');
+ expect(sseFromResponses(sseResponses({ itemSchema: { kind: 'unknown' } }))?.dataKind).toBe(
+ 'text'
+ );
+ });
+
+ it("is 'text' for scalar/literal/enum/null event types", () => {
+ const text: SchemaModel[] = [
+ { kind: 'scalar', scalar: 'string' },
+ { kind: 'literal', value: 'x' },
+ { kind: 'enum', values: ['a'], scalar: 'string' },
+ { kind: 'null' },
+ ];
+ for (const itemSchema of text) {
+ expect(sseFromResponses(sseResponses({ itemSchema }))?.dataKind).toBe('text');
+ }
+ });
+});
diff --git a/packages/client-generator/src/intermediate-representation/build.ts b/packages/client-generator/src/intermediate-representation/build.ts
index bc45b2a5dd..c58661d8a9 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,
+ SseModel,
ResponseHeaderModel,
ScalarKind,
SchemaMetadata,
@@ -538,6 +539,8 @@ function buildOperation(
const extensions = operation as unknown as Record;
const paginationExtension = extensions['x-redoclyPagination'];
+ const sse = sseFromResponses(successResponses);
+
return {
name,
method,
@@ -555,9 +558,38 @@ function buildOperation(
security,
tags: Array.isArray(operation.tags) ? operation.tags.filter((t) => typeof t === 'string') : [],
...(paginationExtension !== undefined ? { paginationExtension } : {}),
+ ...(sse === undefined ? {} : { sse }),
};
}
+/**
+ * The operation's SSE facts, from its `text/event-stream` success response (exact media
+ * type, parameters and case ignored). The event schema prefers the 3.2 `itemSchema` over
+ * the response `schema`, skipping typeless slots; structured kinds stream as JSON,
+ * scalar-ish ones as raw text.
+ */
+export function sseFromResponses(successResponses: ResponseBodyModel[]): SseModel | undefined {
+ const response = successResponses.find(
+ (candidate) => candidate.contentType.split(';')[0].trim().toLowerCase() === 'text/event-stream'
+ );
+ if (response === undefined) return undefined;
+ const declared =
+ response.itemSchema && response.itemSchema.kind !== 'unknown'
+ ? response.itemSchema
+ : response.schema.kind !== 'unknown'
+ ? response.schema
+ : undefined;
+ if (declared === undefined) return { dataKind: 'text' };
+ const streamsJson =
+ declared.kind === 'object' ||
+ declared.kind === 'ref' ||
+ declared.kind === 'array' ||
+ declared.kind === 'record' ||
+ declared.kind === 'union' ||
+ declared.kind === 'intersection';
+ return { eventSchema: declared, dataKind: streamsJson ? 'json' : 'text' };
+}
+
function buildParameter(param: Oas3Parameter, location: string, doc: Oas3Definition): ParamModel {
if (!param.in) {
throw new NotSupportedError(`Parameter ${param.name} at ${location} is missing "in"`);
diff --git a/packages/client-generator/src/intermediate-representation/model.ts b/packages/client-generator/src/intermediate-representation/model.ts
index b0c3e08a3f..b5c9a1f22c 100644
--- a/packages/client-generator/src/intermediate-representation/model.ts
+++ b/packages/client-generator/src/intermediate-representation/model.ts
@@ -227,6 +227,20 @@ export type OperationModel = {
* extensions are untyped). Validated by the pagination emitter, not the IR.
*/
paginationExtension?: unknown;
+ /**
+ * Present exactly when the operation streams Server-Sent Events (a `text/event-stream`
+ * success response). Computed once by the IR builder so no generator re-derives it:
+ * the per-event schema (OpenAPI 3.2 `itemSchema` over the response `schema`; absent
+ * when typeless — the payload types as a string) and whether the runtime should
+ * `JSON.parse` each `data:` payload.
+ */
+ sse?: SseModel;
+};
+
+/** An SSE operation's streaming facts (see `OperationModel.sse`). */
+export type SseModel = {
+ eventSchema?: SchemaModel;
+ dataKind: 'json' | 'text';
};
export type ServiceModel = {
diff --git a/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts b/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts
index d88e752d4b..53a9af627d 100644
--- a/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts
+++ b/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts
@@ -1,8 +1,7 @@
import { logger } from '@redocly/openapi-core';
-import { isSafeIdentifier, sanitizeIdentifier } from '../emitters/identifier.js';
-import { reservedModuleNames } from '../emitters/reserved-names.js';
-import { pascalCase } from '../emitters/support.js';
+import { isSafeIdentifier, pascalCase, sanitizeIdentifier } from '../printers/typescript.js';
+import { reservedModuleNames } from '../reserved-names.js';
import type { ApiModel, OperationModel, SchemaModel } from './model.js';
/**
diff --git a/packages/client-generator/src/emitters/pagination.ts b/packages/client-generator/src/pagination.ts
similarity index 89%
rename from packages/client-generator/src/emitters/pagination.ts
rename to packages/client-generator/src/pagination.ts
index 6d3e96c798..87ac869486 100644
--- a/packages/client-generator/src/emitters/pagination.ts
+++ b/packages/client-generator/src/pagination.ts
@@ -8,15 +8,49 @@
import { isPlainObject, logger } from '@redocly/openapi-core';
-import { schemaAtPointer as resolveSchemaPointer } from '../authoring/schema.js';
+import { schemaAtPointer as resolveSchemaPointer } from './authoring/schema.js';
import {
allOperations,
type ApiModel,
type OperationModel,
type SchemaModel,
-} from '../intermediate-representation/model.js';
-import type { PaginationSpec } from '../runtime/types.js';
-import { isSseOp } from './sse.js';
+} from './intermediate-representation/model.js';
+
+/**
+ * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members).
+ * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value.
+ */
+export type PaginationSpec =
+ | {
+ style: 'cursor';
+ /** The query param the iterator advances with the response's cursor. */
+ param: string;
+ /** Optional page-size query param (recorded for tooling; never set by the runtime). */
+ limitParam?: string;
+ /** Pointer to the next cursor in the page. */
+ nextCursor: string;
+ /** Optional pointer to a boolean "more pages" flag — `false` stops iteration. */
+ hasMore?: string;
+ /** Pointer to the page's item array. */
+ items: string;
+ }
+ | {
+ style: 'offset' | 'page';
+ /** The numeric query param the iterator advances. */
+ param: string;
+ /** Optional page-size query param (recorded for tooling; never set by the runtime). */
+ limitParam?: string;
+ /** Pointer to the page's item array. */
+ items: string;
+ }
+ | {
+ /** RFC 8288: follow the response's `Link` header `rel="next"`; stop when absent. */
+ style: 'link';
+ /** Optional page-size query param (recorded for tooling; never set by the runtime). */
+ limitParam?: string;
+ /** Pointer to the page's item array. */
+ items: string;
+ };
/** The pagination styles the generated runtime can drive. */
export type PaginationStyle = 'cursor' | 'offset' | 'page' | 'link';
@@ -140,7 +174,7 @@ function applyRule(
const misfit = (problem: string): ResolvedPagination =>
explicit ? { error: `${label}: ${problem}` } : {};
- if (isSseOp(op)) return misfit('the operation is a Server-Sent Events stream');
+ if (op.sse !== undefined) return misfit('the operation is a Server-Sent Events stream');
if (valid.style !== 'link') {
const paramField = valid.style === 'cursor' ? 'cursorParam' : 'offsetParam';
const param = valid.style === 'cursor' ? valid.cursorParam! : valid.offsetParam!;
@@ -279,7 +313,7 @@ function ruleShapeProblem(rule: unknown): string | undefined {
}
/** The neutral RFC 6901 schema walker, re-exported under its original name here. */
-export { schemaAtPointer as resolveSchemaPointer } from '../authoring/schema.js';
+export { schemaAtPointer as resolveSchemaPointer } from './authoring/schema.js';
/** A (dereferenced) schema named for a fit-error message; scalars/enums by their scalar. */
function describeSchema(schema: SchemaModel | undefined): string {
diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts
index 6c5bd083f9..694a35f290 100644
--- a/packages/client-generator/src/pipeline.ts
+++ b/packages/client-generator/src/pipeline.ts
@@ -8,15 +8,15 @@
import { logger, stringifyYaml } from '@redocly/openapi-core';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
-import { dirname, resolve, sep } from 'node:path';
+import { dirname, parse, resolve, sep } from 'node:path';
-import type { EmitOptions } from './emitters/emit-options.js';
import { NotSupportedError } from './errors.js';
import { validateSelection } from './generators/meta.js';
import { resolveGeneratorOptions } from './generators/options.js';
import { resolveGenerators } from './generators/resolve.js';
import type {
CodeSample,
+ EmitOptions,
GeneratedFile,
GeneratorDescriptor,
OutputMode,
@@ -25,6 +25,7 @@ import { buildApiModel } from './intermediate-representation/build.js';
import { allOperations, type ApiModel } from './intermediate-representation/model.js';
import { normalizeSwagger2 } from './intermediate-representation/normalize-swagger2.js';
import { loadSpec } from './loader.js';
+import { resolveModelPagination, type ModelPagination } from './pagination.js';
import type { GenerateClientOptions, GenerateClientResult } from './types.js';
/**
@@ -32,12 +33,21 @@ import type { GenerateClientOptions, GenerateClientResult } from './types.js';
* their files. Throws on a duplicate output path so two generators can't
* silently clobber each other. Validation is the caller's job (`validateSelection`).
*/
+// The generated-by banner, comment-marker-free — each generator prepends it in its own
+// comment syntax (the TypeScript family's HEADER renders these same lines with `//`).
+const BANNER_LINES = [
+ 'Generated by @redocly/client-generator — do not edit by hand.',
+ 'Source: OpenAPI description. Re-run `redocly generate-client` to update.',
+];
+
export function runGenerators(
model: ApiModel,
options: {
outputPath: string;
outputMode: OutputMode;
emit: EmitOptions;
+ /** Pagination resolved once for the whole run (see `GeneratorInput.pagination`). */
+ pagination?: ModelPagination;
generators: string[];
registry: Map;
/** Per-generator options, already validated (see `resolveGeneratorOptions`). */
@@ -49,14 +59,19 @@ export function runGenerators(
// Every emitted path must stay under the --output directory: generator modules are
// user-chosen code, but a stray `../` or absolute path must not write elsewhere.
const outputRoot = resolve(dirname(options.outputPath));
+ const { dir, name: stem, ext } = parse(options.outputPath);
+ const output = { path: options.outputPath, dir, stem, ext };
+ const banner = BANNER_LINES;
let documented = false;
for (const name of options.generators) {
const generator = options.registry.get(name)!;
const input = {
model,
- outputPath: options.outputPath,
+ output,
+ banner,
outputMode: options.outputMode,
emit: options.emit,
+ pagination: options.pagination,
selected: options.generators,
options: options.generatorOptions?.get(name) ?? {},
};
@@ -154,12 +169,13 @@ function codeSamplesOverlay(
emit: EmitOptions,
selected: string[],
registry: Map,
- outputPath: string
+ outputPath: string,
+ pagination?: ModelPagination
): string | undefined {
const actions = [];
for (const op of allOperations(model.services)) {
const samples = selected
- .map((name) => registry.get(name)?.sample?.(op, { model, emit, outputPath }))
+ .map((name) => registry.get(name)?.sample?.(op, { model, emit, outputPath, pagination }))
.filter((sample): sample is CodeSample => sample !== undefined);
if (samples.length > 0) {
actions.push({
@@ -213,7 +229,7 @@ export async function generateClient(
// Baking parses TypeScript, so the module loads only when setup is actually used.
let setupBlock: string | undefined;
if (options.setup) {
- const { bakeSetup } = await import('./emitters/setup-bake.js');
+ const { bakeSetup } = await import('./setup-bake.js');
// A relative setup path resolves against `configDir` (cwd when absent), like
// generator specifiers. The CLI pre-resolves its inputs, so they arrive absolute.
const setupPath = resolve(options.configDir ?? process.cwd(), options.setup);
@@ -231,6 +247,9 @@ export async function generateClient(
configDir: options.configDir,
});
+ // ONE pagination resolution for the run: fit-verified, pointers resolved, errors
+ // reported before any generator writes a file.
+ const pagination = resolveModelPagination(model, options.pagination);
const emit: EmitOptions = {
serverUrl: options.serverUrl,
argsStyle: options.argsStyle,
@@ -243,7 +262,7 @@ export async function generateClient(
runtime: options.runtime,
importExt: options.importExt,
goPackage: options.goPackage,
- pagination: options.pagination,
+ pagination,
docs: options.docs,
docsFrontmatter: options.docsFrontmatter,
};
@@ -256,13 +275,14 @@ export async function generateClient(
outputPath,
outputMode: options.outputMode ?? 'single',
emit,
+ pagination,
generators: selected,
generatorOptions,
registry,
});
if (options.codeSamples === true) {
- const overlay = codeSamplesOverlay(model, emit, selected, registry, outputPath);
+ const overlay = codeSamplesOverlay(model, emit, selected, registry, outputPath, pagination);
if (overlay !== undefined) {
files.push({ path: outputPath.replace(/\.[^.]+$/, '.code-samples.yaml'), content: overlay });
}
diff --git a/packages/client-generator/src/plugin.ts b/packages/client-generator/src/plugin.ts
index 9b45e84a27..d27c4311e2 100644
--- a/packages/client-generator/src/plugin.ts
+++ b/packages/client-generator/src/plugin.ts
@@ -18,10 +18,10 @@
// export default defineGenerator({
// name: 'route-map',
// requires: ['typescript'],
-// run({ model, outputPath }) {
+// run({ model, output }) {
// const routes = model.services.flatMap((s) => s.operations)
// .map((op) => ` ${op.name}: '${op.method.toUpperCase()} ${op.path}',`).join('\n');
-// return [{ path: outputPath.replace(/\.ts$/, '.routes.ts'),
+// return [{ path: output.path.replace(/\.ts$/, '.routes.ts'),
// content: `export const routes = {\n${routes}\n} as const;\n` }];
// },
// });
@@ -42,31 +42,43 @@ export function defineGenerator(generator: CustomGenerator): CustomGenerator {
// --- The authoring contract + the data a generator receives -----------------------------------
export type {
+ ArgsStyle,
+ CodeSample,
CustomGenerator,
+ DateType,
+ EmitOptions,
+ ErrorMode,
GeneratedFile,
Generator,
GeneratorInput,
GeneratorName,
+ GeneratorOptionsSchema,
+ OutputAnchor,
OutputMode,
+ SampleContext,
} from './generators/types.js';
-export type { ArgsStyle, ErrorMode } from './emitters/operations.js';
-export type { DateType } from './emitters/types.js';
// --- The intermediate representation (the `model` a generator walks) ---------------------------
+export { allOperations } from './intermediate-representation/model.js';
export type {
ApiModel,
+ DiscriminatorModel,
NamedSchemaModel,
OperationModel,
ParamModel,
PropertyModel,
RequestBodyModel,
ResponseBodyModel,
+ ResponseHeaderModel,
ScalarKind,
SchemaMetadata,
SchemaModel,
+ SecuritySchemeModel,
+ ServerModel,
ServiceModel,
+ SseModel,
} from './intermediate-representation/model.js';
// The TypeScript-emitting renderers (`tsType`, `operationSignature`, …) are exported from
// `@redocly/client-generator/generate`, which also carries the generation entry point —
-// the runtime-only package root stays free of it.
+// the package root stays a small authoring surface.
diff --git a/packages/client-generator/src/emitters/__tests__/__snapshots__/ts-literal.test.ts.snap b/packages/client-generator/src/printers/__tests__/__snapshots__/typescript.test.ts.snap
similarity index 100%
rename from packages/client-generator/src/emitters/__tests__/__snapshots__/ts-literal.test.ts.snap
rename to packages/client-generator/src/printers/__tests__/__snapshots__/typescript.test.ts.snap
diff --git a/packages/client-generator/src/printers/__tests__/printers.test.ts b/packages/client-generator/src/printers/__tests__/printers.test.ts
new file mode 100644
index 0000000000..2060496c4a
--- /dev/null
+++ b/packages/client-generator/src/printers/__tests__/printers.test.ts
@@ -0,0 +1,112 @@
+import { GoPrinter, exported } from '../go.js';
+import { PhpPrinter } from '../php.js';
+import { PythonPrinter } from '../python.js';
+import { TypeScriptPrinter } from '../typescript.js';
+
+// The check that the abstraction is real: four printers fill the same slots, each with
+// its language's answer — not a bag of leftovers (ADR-0021).
+
+describe('naming slots', () => {
+ it('python: pascal types, snake members that report a rename, screaming consts', () => {
+ const py = new PythonPrinter();
+ expect(py.typeName('order-item')).toBe('OrderItem');
+ expect(py.memberName('petType')).toEqual({ identifier: 'pet_type', renamed: true });
+ expect(py.memberName('class')).toEqual({ identifier: 'class_', renamed: true });
+ expect(py.constName('in-progress')).toBe('IN_PROGRESS');
+ expect(py.identifiers(['id', 'id'], ['body'])).toEqual(['id', 'id_2']);
+ });
+
+ it('go: the digit-leading N rule — a `_` prefix would make the field unexported', () => {
+ const go = new GoPrinter();
+ expect(go.typeName('3ds')).toBe('N3ds');
+ expect(exported('order-item')).toBe('OrderItem');
+ expect(go.identifiers(['get-user', 'getUser'])).toEqual(['GetUser', 'GetUser2']);
+ expect(go.packageName('My API!')).toBe('myapi');
+ expect(go.packageName('42')).toBe('client');
+ });
+
+ it('php and typescript keep their conventions', () => {
+ const php = new PhpPrinter();
+ expect(php.typeName('order item')).toBe('OrderItem');
+ expect(php.memberName('list')).toBe('list_');
+ const ts = new TypeScriptPrinter();
+ expect(ts.identifier('foo(){};evil()')).toBe('foo_____evil__');
+ expect(ts.key('valid')).toBe('valid');
+ expect(ts.key('not-valid')).toBe('"not-valid"');
+ expect(ts.identifiers(['a-b', 'a.b'])).toEqual(['a_b', 'a_b_2']);
+ });
+});
+
+describe('string slots — each language a real policy, not JSON by coincidence', () => {
+ const HOSTILE = 'it\'s "x"\n\t\\ €😀';
+
+ it('python: escapes controls, keeps non-ASCII raw, spells a lone surrogate', () => {
+ const py = new PythonPrinter();
+ expect(py.string(HOSTILE)).toBe('"it\'s \\"x\\"\\n\\t\\\\ €😀"');
+ expect(py.string('\u0000')).toBe('"\\x00"');
+ expect(py.string('\ud83d')).toBe('"\\ud83d"'); // lone surrogate stays representable
+ });
+
+ it('go: same shape, but a lone surrogate has no Go spelling and becomes U+FFFD', () => {
+ const go = new GoPrinter();
+ expect(go.string(HOSTILE)).toBe('"it\'s \\"x\\"\\n\\t\\\\ €😀"');
+ expect(go.string('\ud83d')).toBe('"\\uFFFD"');
+ });
+
+ it('typescript: the merged, stricter policy — U+2028/29 AND breakouts', () => {
+ const ts = new TypeScriptPrinter();
+ expect(ts.string('a\u2028b')).toBe('"a\\u2028b"');
+ expect(ts.string('')).toBe('"\\u003C/script\\u003E"');
+ });
+
+ it('php: quotes and backslashes, single-quoted', () => {
+ expect(new PhpPrinter().string("it's \\")).toBe("'it\\'s \\\\'");
+ });
+});
+
+describe('doc slots', () => {
+ it('python: one-line and multi-line docstring forms', () => {
+ const py = new PythonPrinter();
+ py.doc('One line.');
+ py.doc('First.\n\nSecond.');
+ expect(py.toString()).toBe('"""One line."""\n"""First.\n\nSecond.\n"""\n');
+ });
+
+ it('go: consecutive blank comment lines collapse, the way gofmt rewrites them', () => {
+ const go = new GoPrinter();
+ go.doc('Thing', 'Summary.\n\n\n\nMore.');
+ expect(go.toString()).toBe('// Thing — Summary.\n//\n// More.\n');
+ });
+
+ it('php: the @tag form when tags exist, one line otherwise', () => {
+ const php = new PhpPrinter();
+ php.doc('items', 'The items.', ['@return array']);
+ expect(php.toString()).toContain(' * @return array');
+ });
+
+ it('typescript: a star-slash in spec text cannot terminate the comment', () => {
+ const ts = new TypeScriptPrinter();
+ ts.doc('evil */ alert(1) /*');
+ expect(ts.toString()).toContain('evil *\\/ alert(1) /*');
+ });
+});
+
+describe('layout', () => {
+ it('go: toString applies column alignment and the gofmt whitespace shape', () => {
+ const go = new GoPrinter();
+ go.block(
+ 'type X struct {',
+ () => {
+ go.line('Id int64 `json:"id"`');
+ go.line('LongerName string `json:"longerName"`');
+ },
+ '}'
+ );
+ go.blank();
+ go.blank();
+ const out = go.toString();
+ expect(out).toContain('\tId int64 `json:"id"`');
+ expect(out).toContain('\tLongerName string `json:"longerName"`');
+ expect(out.endsWith('}\n')).toBe(true); // trailing blanks trimmed
+ });
+});
diff --git a/packages/client-generator/src/printers/__tests__/typescript.test.ts b/packages/client-generator/src/printers/__tests__/typescript.test.ts
new file mode 100644
index 0000000000..c632b2b678
--- /dev/null
+++ b/packages/client-generator/src/printers/__tests__/typescript.test.ts
@@ -0,0 +1,131 @@
+import {
+ codeLiteral,
+ isIdentifier,
+ safeIdent,
+ sanitizeCodeString,
+ uniqueIdent,
+} from '../typescript.js';
+
+describe('isIdentifier', () => {
+ it('accepts valid identifiers (letters, _, $, digits after the first char)', () => {
+ expect(isIdentifier('foo')).toBe(true);
+ expect(isIdentifier('_foo')).toBe(true);
+ expect(isIdentifier('$foo')).toBe(true);
+ expect(isIdentifier('foo123')).toBe(true);
+ });
+
+ it('rejects names that are not valid identifiers', () => {
+ expect(isIdentifier('foo-bar')).toBe(false);
+ expect(isIdentifier('2fa')).toBe(false);
+ expect(isIdentifier('has space')).toBe(false);
+ expect(isIdentifier('')).toBe(false);
+ });
+});
+
+describe('safeIdent', () => {
+ it('returns a valid, non-reserved name bare', () => {
+ expect(safeIdent('limit')).toBe('limit');
+ });
+
+ it('quotes a reserved word (a bare reserved word would not be a usable key)', () => {
+ expect(safeIdent('default')).toBe('"default"');
+ });
+
+ it('quotes a name that is not a valid identifier', () => {
+ expect(safeIdent('X-Request-Id')).toBe('"X-Request-Id"');
+ });
+});
+
+describe('uniqueIdent', () => {
+ it('keeps a clean identifier unchanged and records it', () => {
+ const used = new Set();
+ expect(uniqueIdent('orderId', used)).toBe('orderId');
+ expect(used.has('orderId')).toBe(true);
+ });
+
+ it('replaces non-identifier characters with underscores', () => {
+ expect(uniqueIdent('pet-id', new Set())).toBe('pet_id');
+ });
+
+ it('prefixes a leading digit with an underscore', () => {
+ expect(uniqueIdent('2fa', new Set())).toBe('_2fa');
+ });
+
+ it('prefixes a reserved word with an underscore', () => {
+ expect(uniqueIdent('new', new Set())).toBe('_new');
+ });
+
+ it('treats strict-mode reserved words as reserved (modules are always strict)', () => {
+ // GitHub's real description has a schema named `package`; `type X = package[]` is TS1214.
+ expect(uniqueIdent('package', new Set())).toBe('_package');
+ expect(uniqueIdent('let', new Set())).toBe('_let');
+ expect(uniqueIdent('await', new Set())).toBe('_await');
+ });
+
+ it('suffixes collisions with an incrementing counter', () => {
+ const used = new Set();
+ expect(uniqueIdent('a.b', used)).toBe('a_b');
+ expect(uniqueIdent('a-b', used)).toBe('a_b_2');
+ expect(uniqueIdent('a b', used)).toBe('a_b_3');
+ });
+});
+
+// Literal expectations for the data-literal renderer (single-line, printer-style).
+const CASES: Array<[string, unknown]> = [
+ ['string', 'plain'],
+ ['string with quotes and backslashes', 'say "hi" \\ done'],
+ ['string with newline', 'a\nb'],
+ ['number', 42],
+ ['negative number', -3.5],
+ ['booleans', true],
+ ['null', null],
+ ['empty array', []],
+ ['array', ['a', 1, false]],
+ ['empty object', {}],
+ ['flat object', { id: 'getPet', method: 'GET', count: 2 }],
+ ['reserved-word key stays bare', { in: 'query', name: 'limit' }],
+ ['non-identifier key is quoted', { 'X-Request-Id': 'header', 'a-b': 1 }],
+ [
+ 'nested descriptor-like shape',
+ {
+ id: 'listOrders',
+ path: '/orders/{id}',
+ params: [
+ { name: 'id', in: 'path' },
+ { name: 'page-size', in: 'query', explode: false },
+ ],
+ security: [[{ scheme: 'Bearer', kind: 'bearer' }]],
+ pagination: { style: 'cursor', cursorParam: 'after', items: '/items' },
+ },
+ ],
+];
+
+describe('codeLiteral', () => {
+ it.each(CASES)('%s', (_label, value) => {
+ expect(codeLiteral(value)).toMatchSnapshot();
+ });
+});
+
+describe('sanitizeCodeString', () => {
+ // The literal must survive being read back: a sanitizer that escapes what
+ // `JSON.stringify` already escaped doubles the backslashes and, for a quote, ends the
+ // string early — emitting TypeScript that does not parse.
+ it.each([
+ ['a newline', 'a\nb'],
+ ['a quote', 'quote " here'],
+ ['a backslash', 'C:\\path'],
+ ['a tab', 'tab\there'],
+ ['a line separator', 'a\u2028b'],
+ ['everything at once', 'a\n"b"\\c\u2029'],
+ ])('round-trips %s', (_label, value) => {
+ expect(JSON.parse(sanitizeCodeString(value))).toBe(value);
+ expect(JSON.parse(codeLiteral(value) as string)).toBe(value);
+ });
+
+ it('escapes the characters that break out of a code context', () => {
+ // `` must not survive intact into an inline script.
+ expect(sanitizeCodeString('')).not.toContain('');
+ expect(sanitizeCodeString('')).toContain('\\u003C');
+ expect(sanitizeCodeString('a\u2028b')).toContain('\\u2028');
+ });
+});
diff --git a/packages/client-generator/src/printers/go.ts b/packages/client-generator/src/printers/go.ts
new file mode 100644
index 0000000000..098b018df9
--- /dev/null
+++ b/packages/client-generator/src/printers/go.ts
@@ -0,0 +1,213 @@
+// The Go syntax printer (ADR-0021). Go's extensions carry knowledge that must not be
+// re-derived: `typeName`/`memberName` apply the digit-leading `N` rule (a `_` prefix
+// means UNexported, so `encoding/json` would silently skip the field), and `layout` is
+// applied by `toString()` because CI commonly runs `gofmt -l` and fails on any file it
+// would reformat — column padding cannot be computed line-by-line, since the width for
+// the first field depends on the longest field in a run that has not been emitted yet.
+
+import { identifierFor, RESERVED_WORDS } from '../authoring/naming.js';
+import { Printer } from '../authoring/printer.js';
+import { docText } from '../authoring/schema.js';
+
+const GO = RESERVED_WORDS.go;
+
+export class GoPrinter extends Printer {
+ constructor() {
+ super('\t');
+ }
+
+ /** An exported type name: PascalCase, with the digit-leading `N` rule. */
+ typeName(name: string): string {
+ return exported(name);
+ }
+
+ /** An exported field/method name — same rule as `typeName`; Go has one namespace. */
+ memberName(name: string): string {
+ return exported(name);
+ }
+
+ /** A local/argument name: camelCase, keyword-safe. */
+ identifier(name: string): string {
+ return identifierFor(name, { style: 'camel', reserved: GO });
+ }
+
+ /** Exported names made unique among themselves and the caller's taken set (`Id`, `Id2`). */
+ identifiers(names: readonly string[], taken?: Iterable): string[] {
+ const used = new Set(taken ?? []);
+ return names.map((name) => {
+ const base = exported(name);
+ let ident = base;
+ for (let suffix = 2; used.has(ident); suffix++) ident = `${base}${suffix}`;
+ used.add(ident);
+ return ident;
+ });
+ }
+
+ /** A package clause name: lower-case letters and digits only, never empty. */
+ packageName(name: string): string {
+ const cleaned = name.toLowerCase().replace(/[^a-z0-9]/g, '');
+ return cleaned === '' || /^[0-9]/.test(cleaned) ? 'client' : cleaned;
+ }
+
+ /**
+ * A double-quoted Go string literal for any spec-supplied text. Controls are escaped;
+ * a lone surrogate (a JS string can carry one) has no Go spelling — `\uD800` is an
+ * invalid code point to the compiler — so it becomes U+FFFD; everything else,
+ * non-ASCII included, is written as itself, because generated files are UTF-8.
+ */
+ string(value: string): string {
+ let out = '"';
+ for (const char of value) {
+ const code = char.codePointAt(0)!;
+ if (char === '\\') out += '\\\\';
+ else if (char === '"') out += '\\"';
+ else if (char === '\n') out += '\\n';
+ else if (char === '\r') out += '\\r';
+ else if (char === '\t') out += '\\t';
+ else if (code < 0x20 || code === 0x7f) out += `\\x${code.toString(16).padStart(2, '0')}`;
+ else if (code >= 0xd800 && code <= 0xdfff) out += '\\uFFFD';
+ else out += char;
+ }
+ return out + '"';
+ }
+
+ /** JSON-ish data as a Go expression (`map[string]any` / `[]any` composites). */
+ literal(value: unknown): string {
+ if (value === null || value === undefined) return 'nil';
+ if (typeof value === 'boolean' || typeof value === 'number') return String(value);
+ if (typeof value === 'string') return this.string(value);
+ if (Array.isArray(value)) {
+ return `[]any{${value.map((item) => this.literal(item)).join(', ')}}`;
+ }
+ const entries = Object.entries(value as Record)
+ .map(([key, entry]) => `${this.string(key)}: ${this.literal(entry)}`)
+ .join(', ');
+ return `map[string]any{${entries}}`;
+ }
+
+ /** A `//` line comment. */
+ comment(text: string): this {
+ for (const line of docText(text)) this.line(line === '' ? '//' : `// ${line}`);
+ return this;
+ }
+
+ /** A doc comment: `// Name — summary`, blank lines collapsed the way gofmt rewrites them. */
+ doc(name: string, description?: string): this {
+ const lines = docText(description);
+ if (lines.length === 0) return this;
+ this.line(`// ${name} — ${lines[0]}`);
+ let previousWasBlank = false;
+ for (const line of lines.slice(1)) {
+ if (line === '') {
+ if (!previousWasBlank) this.line('//');
+ previousWasBlank = true;
+ continue;
+ }
+ this.line(`// ${line}`);
+ previousWasBlank = false;
+ }
+ return this;
+ }
+
+ /** gofmt-clean text: column alignment plus the whitespace shape gofmt produces. */
+ layout(source: string): string {
+ return gofmtShape(alignGoColumns(source));
+ }
+
+ override toString(): string {
+ return this.layout(super.toString());
+ }
+}
+
+/** An exported Go identifier: PascalCase, digit-leading names get `N` (never `_`). */
+export function exported(name: string): string {
+ const ident = identifierFor(name, { style: 'pascal', reserved: GO });
+ return ident.startsWith('_') ? `N${ident.slice(1)}` : ident;
+}
+
+/**
+ * The whitespace shape gofmt produces: never more than one blank line, and exactly one
+ * trailing newline. Both entry points below run through it, so the models view is as
+ * gofmt-clean as the full client.
+ */
+function gofmtShape(source: string): string {
+ return `${source.replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
+}
+
+/**
+ * Align columns the way gofmt does, so the emitted file is already idiomatic and a
+ * `gofmt` run is a no-op. gofmt pads with spaces inside a contiguous run of similar
+ * lines: struct fields align their type and tag columns, `const`/`var` entries align
+ * their type and `=`. A line that doesn't fit the shape (a comment, a blank line, a
+ * type containing spaces) ends the run, exactly like gofmt's tabwriter.
+ */
+function alignGoColumns(source: string): string {
+ const lines = source.split('\n');
+ const out = [...lines];
+ // `\tName Type` optionally followed by a `json:"…"` tag, `\tName Type = value`, or a
+ // quoted map key. A statement starting with a Go keyword (`case "x":`, `return y`) is
+ // NOT a declaration and must never be padded.
+ const FIELD = /^(\t+)([A-Za-z_]\w*) (\S+)( `[^`]*`)?$/;
+ const CONST = /^(\t+)([A-Za-z_]\w*) (\S+) = (.+)$/;
+ const ENTRY = /^(\t+)("(?:[^"\\]|\\.)*":) (.+)$/;
+
+ const flush = (run: Array<{ index: number; parts: string[]; indent: string }>): void => {
+ if (run.length < 2) return;
+ const widths: number[] = [];
+ for (const { parts } of run) {
+ parts.forEach((part, column) => {
+ // The last column never needs padding.
+ if (column < parts.length - 1) widths[column] = Math.max(widths[column] ?? 0, part.length);
+ });
+ }
+ for (const { index, parts, indent } of run) {
+ const padded = parts.map((part, column) =>
+ column < parts.length - 1 ? part.padEnd(widths[column] ?? 0) : part
+ );
+ out[index] = indent + padded.join(' ').trimEnd();
+ }
+ };
+
+ let run: Array<{ index: number; parts: string[]; indent: string }> = [];
+ let runKind: 'field' | 'const' | 'entry' | undefined;
+ lines.forEach((line, index) => {
+ const entryMatch = ENTRY.exec(line);
+ const constMatch = entryMatch === null ? CONST.exec(line) : null;
+ const fieldCandidate = entryMatch === null && constMatch === null ? FIELD.exec(line) : null;
+ // `case`, `return`, `var`, … start statements, not declarations.
+ const fieldMatch =
+ fieldCandidate !== null && !GO.has(fieldCandidate[2]) ? fieldCandidate : null;
+ const kind =
+ entryMatch !== null
+ ? 'entry'
+ : constMatch !== null
+ ? 'const'
+ : fieldMatch !== null
+ ? 'field'
+ : undefined;
+ if (kind === undefined || kind !== runKind) {
+ flush(run);
+ run = [];
+ runKind = kind;
+ }
+ if (entryMatch !== null) {
+ run.push({ index, indent: entryMatch[1], parts: [entryMatch[2], entryMatch[3]] });
+ return;
+ }
+ if (constMatch !== null) {
+ run.push({
+ index,
+ indent: constMatch[1],
+ parts: [constMatch[2], constMatch[3], '=', constMatch[4]],
+ });
+ return;
+ }
+ if (fieldMatch !== null) {
+ const parts = [fieldMatch[2], fieldMatch[3]];
+ if (fieldMatch[4] !== undefined) parts.push(fieldMatch[4].trimStart());
+ run.push({ index, indent: fieldMatch[1], parts });
+ }
+ });
+ flush(run);
+ return out.join('\n');
+}
diff --git a/packages/client-generator/src/printers/index.ts b/packages/client-generator/src/printers/index.ts
new file mode 100644
index 0000000000..be4a555680
--- /dev/null
+++ b/packages/client-generator/src/printers/index.ts
@@ -0,0 +1,9 @@
+// The four language printers (ADR-0021): the common `Printer` owns structure, each of
+// these owns one language's syntax. They fill the same slots — `typeName`, `memberName`,
+// `identifier`, `identifiers`, `string`, `literal`, `comment`, `doc`, a baked-in indent
+// unit, and (where the language demands one) a `layout` pass applied by `toString()`.
+
+export { GoPrinter, exported } from './go.js';
+export { PhpPrinter } from './php.js';
+export { PythonPrinter } from './python.js';
+export { TypeScriptPrinter } from './typescript.js';
diff --git a/packages/client-generator/src/printers/php.ts b/packages/client-generator/src/printers/php.ts
new file mode 100644
index 0000000000..b3a6d7e71b
--- /dev/null
+++ b/packages/client-generator/src/printers/php.ts
@@ -0,0 +1,70 @@
+// The PHP syntax printer (ADR-0021). PHP's extension: `doc` takes `@tag` lines, because
+// `array` and `\Generator` erase element types — the docblock carries what they hold.
+
+import { identifierFor, RESERVED_WORDS, uniqueIdentifiers } from '../authoring/naming.js';
+import { Printer } from '../authoring/printer.js';
+import { docText } from '../authoring/schema.js';
+
+const PHP = RESERVED_WORDS.php;
+
+export class PhpPrinter extends Printer {
+ constructor() {
+ super(' ');
+ }
+
+ /** A class/enum name: PascalCase, keyword-safe. */
+ typeName(name: string): string {
+ return identifierFor(name, { style: 'pascal', reserved: PHP });
+ }
+
+ /** A property/method name: camelCase, keyword-safe. */
+ memberName(name: string): string {
+ return identifierFor(name, { style: 'camel', reserved: PHP });
+ }
+
+ /** A variable/argument name (without the `$`). */
+ identifier(name: string): string {
+ return identifierFor(name, { style: 'camel', reserved: PHP });
+ }
+
+ /** Names made unique among themselves and the caller's taken set (`id`, `id2`). */
+ identifiers(names: readonly string[], taken?: Iterable): string[] {
+ return uniqueIdentifiers(names, { style: 'camel', reserved: PHP, taken });
+ }
+
+ /** `'…'` with backslashes and quotes escaped — safe for any spec-supplied text. */
+ string(value: string): string {
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
+ }
+
+ /** JSON-ish data as a PHP expression (arrays for both lists and maps). */
+ literal(value: unknown): string {
+ if (value === null || value === undefined) return 'null';
+ if (typeof value === 'boolean' || typeof value === 'number') return String(value);
+ if (typeof value === 'string') return this.string(value);
+ if (Array.isArray(value)) return `[${value.map((item) => this.literal(item)).join(', ')}]`;
+ const entries = Object.entries(value as Record)
+ .map(([key, entry]) => `${this.string(key)} => ${this.literal(entry)}`)
+ .join(', ');
+ return `[${entries}]`;
+ }
+
+ /** A `//` line comment. */
+ comment(text: string): this {
+ for (const line of docText(text)) this.line(line === '' ? '//' : `// ${line}`);
+ return this;
+ }
+
+ /** A docblock: one line without tags, the `@tag` form with them. */
+ doc(name: string, description?: string, tags: string[] = []): this {
+ const lines = docText(description);
+ if (lines.length === 0 && tags.length === 0) return this;
+ const summary = lines.length === 0 ? name : `${name} — ${lines.join(' ')}`;
+ if (tags.length === 0) return this.line(`/** ${summary} */`);
+ this.line('/**');
+ this.line(` * ${summary}`);
+ this.line(' *');
+ for (const tag of tags) this.line(` * ${tag}`);
+ return this.line(' */');
+ }
+}
diff --git a/packages/client-generator/src/printers/python.ts b/packages/client-generator/src/printers/python.ts
new file mode 100644
index 0000000000..3961128f86
--- /dev/null
+++ b/packages/client-generator/src/printers/python.ts
@@ -0,0 +1,94 @@
+// The Python syntax printer (ADR-0021): structure from the common `Printer`, syntax —
+// identifier safety, string escaping, literal rendering, comment and docstring form —
+// owned here. The generator owns shape (classes, signatures, field lists) as template
+// literals; the test for what belongs on the printer is "is there exactly one right answer?"
+
+import { identifierFor, RESERVED_WORDS, uniqueIdentifiers } from '../authoring/naming.js';
+import { Printer } from '../authoring/printer.js';
+import { docText } from '../authoring/schema.js';
+
+const PY = RESERVED_WORDS.python;
+
+export class PythonPrinter extends Printer {
+ constructor() {
+ super(' ');
+ }
+
+ /** A class name: PascalCase, keyword-safe. */
+ typeName(name: string): string {
+ return identifierFor(name, { style: 'pascal', reserved: PY });
+ }
+
+ /** A field/parameter name, reporting a rename so the caller can record the wire name. */
+ memberName(name: string): { identifier: string; renamed: boolean } {
+ const identifier = identifierFor(name, { style: 'snake', reserved: PY });
+ return { identifier, renamed: identifier !== name };
+ }
+
+ /** A local/argument name: snake_case, keyword-safe. */
+ identifier(name: string): string {
+ return identifierFor(name, { style: 'snake', reserved: PY });
+ }
+
+ /** Names made unique among themselves and the caller's taken set (`id`, `id_2`). */
+ identifiers(names: readonly string[], taken?: Iterable): string[] {
+ return uniqueIdentifiers(names, { style: 'snake', reserved: PY, taken });
+ }
+
+ /** A module-level constant name: SCREAMING_SNAKE. */
+ constName(name: string): string {
+ return identifierFor(name, { style: 'screaming', reserved: PY });
+ }
+
+ /**
+ * A double-quoted Python string literal for any spec-supplied text. Controls are
+ * escaped; a lone surrogate (a JS string can carry one) stays representable as its
+ * `\uXXXX` escape; everything else — non-ASCII included — is written as itself,
+ * because generated files are UTF-8.
+ */
+ string(value: string): string {
+ let out = '"';
+ for (const char of value) {
+ const code = char.codePointAt(0)!;
+ if (char === '\\') out += '\\\\';
+ else if (char === '"') out += '\\"';
+ else if (char === '\n') out += '\\n';
+ else if (char === '\r') out += '\\r';
+ else if (char === '\t') out += '\\t';
+ else if (code < 0x20 || code === 0x7f) out += `\\x${code.toString(16).padStart(2, '0')}`;
+ else if (code >= 0xd800 && code <= 0xdfff) out += `\\u${code.toString(16).padStart(4, '0')}`;
+ else out += char;
+ }
+ return out + '"';
+ }
+
+ /** JSON-ish data as a Python expression (dicts/lists/strings/numbers/bools/None). */
+ literal(value: unknown): string {
+ if (value === null || value === undefined) return 'None';
+ if (value === true) return 'True';
+ if (value === false) return 'False';
+ if (typeof value === 'number') return String(value);
+ if (typeof value === 'string') return this.string(value);
+ if (Array.isArray(value)) return `[${value.map((item) => this.literal(item)).join(', ')}]`;
+ const entries = Object.entries(value as Record)
+ .map(([key, entry]) => `${this.string(key)}: ${this.literal(entry)}`)
+ .join(', ');
+ return `{${entries}}`;
+ }
+
+ /** A `#` line comment (multi-line text becomes one `#` line per line). */
+ comment(text: string): this {
+ for (const line of docText(text)) this.line(line === '' ? '#' : `# ${line}`);
+ return this;
+ }
+
+ /** A docstring: Python's one-line and multi-line forms differ, and this owns the rule. */
+ doc(description?: string): this {
+ const lines = docText(description);
+ if (lines.length === 0) return this;
+ if (lines.length === 1) return this.line(`"""${lines[0]}"""`);
+ this.line(`"""${lines[0]}`);
+ for (const line of lines.slice(1)) this.line(line);
+ return this.line('"""');
+ }
+}
diff --git a/packages/client-generator/src/printers/typescript.ts b/packages/client-generator/src/printers/typescript.ts
new file mode 100644
index 0000000000..30de4de1b1
--- /dev/null
+++ b/packages/client-generator/src/printers/typescript.ts
@@ -0,0 +1,283 @@
+// The TypeScript syntax printer (ADR-0021). TypeScript's extension is `key(name)` — a
+// bare-or-quoted object key; no other output language has quotable keys. Its `string`
+// carries the merged escaping policy: JSON escaping plus U+2028/U+2029 (line terminators
+// in JS source) plus `<`/`>` (a `` breakout when output lands in an inline
+// script) — previously two escapers with different protections, split by import site.
+
+import { RESERVED_WORDS } from '../authoring/naming.js';
+import { Printer } from '../authoring/printer.js';
+import { docText } from '../authoring/schema.js';
+import type { SchemaMetadata } from '../intermediate-representation/model.js';
+
+export class TypeScriptPrinter extends Printer {
+ constructor() {
+ super(' ');
+ }
+
+ /** A type name: PascalCase over an already-sanitized name (the IR coerces op names). */
+ typeName(name: string): string {
+ return pascalCase(sanitizeIdentifier(name));
+ }
+
+ /** A member (binding) name: sanitized, keyword-safe. */
+ memberName(name: string): string {
+ return sanitizeIdentifier(name);
+ }
+
+ /** A local/argument name: sanitized, keyword-safe. */
+ identifier(name: string): string {
+ return sanitizeIdentifier(name);
+ }
+
+ /** Names made unique among themselves and the caller's taken set (`id`, `id_2`). */
+ identifiers(names: readonly string[], taken?: Iterable): string[] {
+ const used = new Set(taken ?? []);
+ return names.map((name) => uniqueIdent(name, used));
+ }
+
+ /** An object key: bare when it is a valid non-reserved identifier, quoted otherwise. */
+ key(name: string): string {
+ return isSafeIdentifier(name) ? name : this.string(name);
+ }
+
+ /** A string literal that cannot escape the code context it lands in. */
+ string(value: string): string {
+ return sanitizeCodeString(value);
+ }
+
+ /** JSON-ish data as TypeScript source text. */
+ literal(value: unknown): string {
+ return codeLiteral(value);
+ }
+
+ /** A `//` line comment. */
+ comment(text: string): this {
+ for (const line of docText(text)) this.line(line === '' ? '//' : `// ${line}`);
+ return this;
+ }
+
+ /** A JSDoc block; a star-slash in spec text is escaped so it cannot terminate it. */
+ doc(description?: string): this {
+ const lines = docText(description);
+ if (lines.length === 0) return this;
+ this.line('/**');
+ for (const line of lines) this.line(line === '' ? ' *' : ` * ${line.replace(/\*\//g, '*\\/')}`);
+ return this.line(' */');
+ }
+}
+
+// ─── Identifier mechanics ───
+
+// Identifier sanitization — mapping OpenAPI names (which may contain `-`, `.`,
+// spaces, or be reserved words) onto valid TypeScript identifiers. Pure string
+// logic with no dependency on the IR or other emitters.
+
+/** Matches a string that is already a valid JS identifier (ignoring reserved words). */
+const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
+
+// One list for the package: `identifierFor` (suffix convention) reads the same set.
+const TS_RESERVED = RESERVED_WORDS.typescript;
+
+/** True when `name` matches the JS identifier grammar (reserved words still pass). */
+export function isIdentifier(name: string): boolean {
+ return IDENT_RE.test(name);
+}
+
+/** True when `name` is a valid JS identifier AND not a reserved word — safe as a binding name. */
+export function isSafeIdentifier(name: string): boolean {
+ return IDENT_RE.test(name) && !TS_RESERVED.has(name);
+}
+
+/**
+ * Coerce an arbitrary spec-supplied name into a valid, non-reserved JS identifier
+ * (no uniqueness guarantee — see `uniqueIdent`). Non-identifier characters become
+ * `_`; an empty result, a leading digit, or a reserved word is prefixed with `_`.
+ * This is the security boundary for any name that lands in a declaration slot —
+ * `ts.factory.createIdentifier` prints its text verbatim, so an unsanitized name
+ * like `foo(){};evil()` would emit as executable code.
+ */
+export function sanitizeIdentifier(name: string): string {
+ let base = name.replace(/[^A-Za-z0-9_$]/g, '_');
+ if (base === '' || /^[0-9]/.test(base) || TS_RESERVED.has(base)) base = `_${base}`;
+ return base;
+}
+
+/**
+ * A double-quoted TS string literal for generated code. One policy for the whole
+ * package — the stricter of the two that used to exist: U+2028/U+2029 (line terminators
+ * in JS source) AND `<`/`>` (a `` breakout when output lands in an inline
+ * script). Which protection applied used to depend on which escaper the caller imported.
+ */
+const CODE_UNSAFE: Record = {
+ '<': '\\u003C',
+ '>': '\\u003E',
+ '\u2028': '\\u2028',
+ '\u2029': '\\u2029',
+};
+
+export function codeString(value: string): string {
+ return JSON.stringify(value).replace(/[<>\u2028\u2029]/g, (char) => CODE_UNSAFE[char]);
+}
+
+/**
+ * Render `name` as an object key or property name: bare when it is a valid,
+ * non-reserved identifier, quoted otherwise. Safe only where quoting is legal
+ * (object keys, property signatures) — not for binding names; use `uniqueIdent`
+ * there.
+ */
+export function safeIdent(name: string): string {
+ if (IDENT_RE.test(name) && !TS_RESERVED.has(name)) {
+ return name;
+ }
+ return codeString(name);
+}
+
+/**
+ * `sanitizeIdentifier(name)` made unique within `used` (which it mutates):
+ * collisions get a `_2`, `_3`, … suffix. Used wherever a name lands in a binding
+ * slot that — unlike an object key — cannot be quoted (function/type/parameter
+ * names), so `safeIdent`'s quote-on-failure fallback would not compile.
+ */
+export function uniqueIdent(name: string, used: Set]