diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md new file mode 100644 index 0000000000..42ee4195d2 --- /dev/null +++ b/.changeset/agent-friendly-generators.md @@ -0,0 +1,6 @@ +--- +'@redocly/client-generator': minor +'@redocly/cli': minor +--- + +Added agent-friendly client generation: `python`, `go`, `php`, and `cli` generators beside the TypeScript client, each self-documenting with `--docs`, configurable per generator, and available as source in your own repository through `eject-generator`. diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md new file mode 100644 index 0000000000..faf24da081 --- /dev/null +++ b/.claude/rules/architecture.md @@ -0,0 +1,47 @@ +# Repository architecture + +Where things live, so a change lands in the right package. + +This is a TypeScript monorepo with npm workspaces containing four packages: + +## `packages/core` (@redocly/openapi-core) + +The heart of the project. +Handles all OpenAPI/AsyncAPI linting, validation, bundling, and decoration logic. +This package is also used in external apps such as `language-server` and `vs-code-extension`. + +Key directories: + +- `src/rules/` — Built-in linting rules, organized by spec type (`oas2/`, `oas3/`, `oas3_1/`, `async2/`, `async3/`, `arazzo/`, `common/`). Each rule is its own file. +- `src/config/` — Configuration loading and resolution (reads `redocly.yaml`). +- `src/decorators/` — Built-in decorators for transforming API descriptions. +- `src/bundle/` — Bundling logic that resolves `$ref` across multiple files. +- `src/resolve.ts` — Document resolution for multi-file specs (local and remote). +- `src/types/` — TypeScript type definitions for OAS2, OAS3, AsyncAPI, Arazzo. + +## `packages/cli` (@redocly/cli) + +User-facing CLI layer built on top of core. +Uses yargs for argument parsing. + +- `src/index.ts` — Main command dispatcher. +- `src/commands/` — One file per command. +- Commands use `commandWrapper()` for consistent output, config loading, config linting, and exit codes (0 = success, 1 = execution error, 2 = config error). + +## `packages/respect-core` (@redocly/respect-core) + +API contract testing framework. +Validates real API responses against OpenAPI/Arazzo specs. + +- `src/run.ts` — Test execution logic. +- `src/modules/` — Core testing modules, including runtime expression evaluation. + +## `packages/client-generator` (@redocly/client-generator) + +Experimental package for generating clients from OpenAPI descriptions — the TypeScript client +plus the `python`, `go`, and `php` SDKs, the generated CLI, and its Markdown reference. + +- `src/intermediate-representation/` — the language-neutral API model every generator reads. +- `src/emitters/` — the renderers that turn that model into source text. +- `src/generators/` — one folder per generator: a thin entry plus the design skill it must match. +- `src/authoring/` — the language-neutral toolkit generators are written with, ours and users'. diff --git a/.claude/rules/core-principles.md b/.claude/rules/core-principles.md index cf785aa508..3706d5b37f 100644 --- a/.claude/rules/core-principles.md +++ b/.claude/rules/core-principles.md @@ -19,7 +19,7 @@ Release and commit mechanics are procedures, not principles — they live in 1. Respect the core patterns: Walker, Visitors, and Nodes. New rules and decorators follow this pattern instead of using regex or manual drilling objects described by the supported specifications. - The full guide is in [`rules-system.md`](./rules-system.md). + The full guide is in [the `rules-system` skill](../skills/rules-system/SKILL.md). 1. Explain in chat, not in files. Don't create explanation, summary, or design files unless asked. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 722c27c7b4..21ad5eee8f 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -2,10 +2,52 @@ 1. Write meaningful tests that exercise real behavior — not tests that exist only to raise coverage. One focused, clear test is enough. -1. Rule tests are unit tests by convention: parse a YAML document, run `lintDocument`, and assert with `toMatchInlineSnapshot` — a behavior test in itself (given this input, these problems). +1. A unit test lives in a `__tests__` folder beside the file it tests, and mirrors its name: + `src/commands/eject-generator.ts` is tested by `src/commands/__tests__/eject-generator.test.ts`. + Do not rebuild the source tree inside a `__tests__` folder (`src/__tests__/commands/…`) — the + older tests that do are historical, and a reviewer should not have to guess which layout a + new test follows. One module gets one test file: split a long one by `describe`, not by adding + a second file for the same source. +1. Rule tests are unit tests by convention: parse a YAML document, run `lintDocument`, and assert + with `toMatchInlineSnapshot` — a behavior test in itself (given this input, these problems). Generate new snapshots and update stale ones as part of the change. + + The pattern — parse, lint, assert on the whole output: + + ```ts + import { outdent } from 'outdent'; + import { parseYamlToDocument, replaceSourceWithRef } from '../../../../__tests__/utils.js'; + import { createConfig } from '../../../config/index.js'; + import { lintDocument } from '../../../lint.js'; + import { BaseResolver } from '../../../resolve.js'; + + describe('Oas3 no-my-rule', () => { + it('should report a violation', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.0.0 + ... + `, + 'foobar.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { 'no-my-rule': 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`...`); + }); + }); + ``` + 1. Compile before testing. + Unit tests import from `lib/` (compiled output), not `src/` — run `npm run compile` after every change. + 1. Run the full suite (`npm test`) when you touch core linting logic, and make sure all tests pass in CI. +1. Client generation has its own suite: `npm run client-generators` runs the client-generator unit tests plus the `tests/e2e/generate-client` bars (which compile real Python/Go/PHP/TypeScript output). + Run it for any generation change; `npm run e2e` does not include those tests. 1. Coverage thresholds (`vitest.config.ts`) are a guide, not a number to game. If a feature or fix is already covered by e2e tests, propose lowering the threshold rather than padding the suite with unit tests that only chase coverage. diff --git a/.claude/skills/redocly-cli/SKILL.md b/.claude/skills/redocly-cli/SKILL.md index 2733049c93..fc3facff9b 100644 --- a/.claude/skills/redocly-cli/SKILL.md +++ b/.claude/skills/redocly-cli/SKILL.md @@ -126,7 +126,7 @@ Configure it durably under a `client` block in `redocly.yaml` instead of flags: ```yaml client: - generators: [sdk, zod] # add-ons: tanstack-query, swr, mock, transformers, or a plugin path + generators: [typescript, zod] # add-ons: tanstack-query, swr, mock, transformers, or a plugin path outputMode: split pagination: # config-only, no CLI flag style: cursor diff --git a/.claude/rules/rules-system.md b/.claude/skills/rules-system/SKILL.md similarity index 95% rename from .claude/rules/rules-system.md rename to .claude/skills/rules-system/SKILL.md index 049ee5ddd5..2ba3f18d82 100644 --- a/.claude/rules/rules-system.md +++ b/.claude/skills/rules-system/SKILL.md @@ -1,3 +1,8 @@ +--- +name: rules-system +description: How to write built-in lint rules and decorators for packages/core — the Walker/Visitors/Nodes pattern, visitor hooks, the ctx object, rule registration, and stateful rule examples. Use when adding or changing a rule, decorator, or preprocessor. +--- + ## Rules System: Walker, Visitors, and Nodes This is the most important pattern to understand when working in `packages/core`. diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index fbf5de851e..bd171a554b 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -37,10 +37,24 @@ jobs: uses: davelosert/vitest-coverage-report-action@d63aa97db4c0319f304f1787689de1ca548365cf # v2.11.1 e2e: - # The e2e suite is split across shards so no single runner carries the whole set. - # Running all suites in one step was cancelled mid-run by the Actions service once the - # generate-client suites grew past ~28 (a healthy runner, no resource exhaustion); - # each shard stays well under that. + # Everything under tests/e2e EXCEPT generate-client, which has its own job below. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 24 + cache: npm + - name: Install dependencies + run: npm ci + - name: E2E Tests + run: npm run e2e + + client-generators: + # Client generation has its own job: the client-generator unit tests plus the + # generate-client e2e bars, which compile real Python, Go, PHP, and TypeScript output + # (including big real-world descriptions) — the slowest tests we have, needing + # toolchains nothing else does. Adding a language bar here cannot slow the shared e2e job. runs-on: ubuntu-latest strategy: fail-fast: false @@ -52,10 +66,22 @@ jobs: with: node-version: 24 cache: npm + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + - name: Install httpx and pydantic (the Python import bars need them) + run: pip install httpx pydantic + # Go, gofmt, and php come with the runner image; a bar whose toolchain is missing + # skips itself, so a thinner image degrades coverage instead of failing the job. + - name: Cache the pinned GitHub REST description + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: tests/e2e/generate-client/.cache + key: large-descriptions-${{ hashFiles('tests/e2e/generate-client/large-descriptions.test.ts') }} - name: Install dependencies run: npm ci - - name: E2E Tests (shard ${{ matrix.shard }}/2) - run: npm run e2e -- --shard=${{ matrix.shard }}/2 + - name: Client generator tests (shard ${{ matrix.shard }}/2) + run: npm run client-generators -- --shard=${{ matrix.shard }}/2 examples: # The examples gitignore their generated clients (only zero-install-quickstart commits diff --git a/.gitignore b/.gitignore index f2173085e2..79060ae38b 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ __changesets__.json **/.claude/agent-registry.json **/.claude/agent-memory-local **/.claude/first-run -**/.claude/assistant-daemon-state.json \ No newline at end of file +**/.claude/assistant-daemon-state.json +__pycache__/ diff --git a/.oxfmtrc.json b/.oxfmtrc.json index fc6793d2c6..c3005c873d 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -11,6 +11,7 @@ "packages/core/src/rules/common/__tests__/fixtures/invalid-yaml.yaml", "tests/performance/api-definitions/", "tests/e2e/generate-client/examples/*/src/api/", + "tests/e2e/generate-client/examples/*/generators/", "tests/e2e/generate-client/*-consumer/api*.ts", "tests/smoke/**/*.yaml", "snapshot*.txt", diff --git a/AGENTS.md b/AGENTS.md index ea29dcee6a..93fb3604a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,9 +46,12 @@ npm run unit -- -t 'test name pattern' # Update snapshots npm run unit -- -u -# Run e2e tests +# Run e2e tests (everything under tests/e2e except generate-client) npm run e2e +# Run every generator test (client-generator unit + generate-client e2e) +npm run client-generators + # Run the full test suite (compile + typecheck + unit + e2e) npm test @@ -64,47 +67,13 @@ npm run cli -- lint openapi.yaml ## Architecture -This is a TypeScript monorepo with npm workspaces containing four packages: - -### `packages/core` (@redocly/openapi-core) - -The heart of the project. -Handles all OpenAPI/AsyncAPI linting, validation, bundling, and decoration logic. -This package is also used in external apps such as `language-server` and `vs-code-extension`. - -Key directories: - -- `src/rules/` — Built-in linting rules, organized by spec type (`oas2/`, `oas3/`, `oas3_1/`, `async2/`, `async3/`, `arazzo/`, `common/`). Each rule is its own file. -- `src/config/` — Configuration loading and resolution (reads `redocly.yaml`). -- `src/decorators/` — Built-in decorators for transforming API descriptions. -- `src/bundle/` — Bundling logic that resolves `$ref` across multiple files. -- `src/resolve.ts` — Document resolution for multi-file specs (local and remote). -- `src/types/` — TypeScript type definitions for OAS2, OAS3, AsyncAPI, Arazzo. - -### `packages/cli` (@redocly/cli) - -User-facing CLI layer built on top of core. -Uses yargs for argument parsing. - -- `src/index.ts` — Main command dispatcher. -- `src/commands/` — One file per command. -- Commands use `commandWrapper()` for consistent output, config loading, config linting, and exit codes (0 = success, 1 = execution error, 2 = config error). - -### `packages/respect-core` (@redocly/respect-core) - -API contract testing framework. -Validates real API responses against OpenAPI/Arazzo specs. - -- `src/run.ts` — Test execution logic. -- `src/modules/` — Core testing modules, including runtime expression evaluation. - -### `packages/client-generator` (@redocly/client-generator) - -Experimental package for generating TypeScript clients from OpenAPI specs. +Where each package sits, and the key directories inside it, are in +[`.claude/rules/architecture.md`](./.claude/rules/architecture.md) — read it before a change lands +in the wrong package. ## Build System -`packages/core`, `packages/respect-core`, and `packages/client-generator` are compiled by TypeScript (`tsc -b tsconfig.build.json`). +`packages/core` and `packages/respect-core` are compiled by TypeScript (`tsc -b tsconfig.build.json`). `packages/cli` is bundled by esbuild (`packages/cli/scripts/build.mjs`) — it produces `lib/index.js` (entry chunk, ~450 kB) and lazy chunks under `lib/chunks/` (redoc + react, loaded only when `build-docs` runs). The root `npm run compile` runs both steps: tsc for core/respect-core, then the esbuild bundle for the CLI. @@ -114,7 +83,7 @@ The published CLI package ships from a staged `.publish/` directory (created by Linting in `packages/core` rests on three concepts: the **Walker** traverses the parsed API description and resolves `$ref`s, **Visitors** are objects keyed by **Node** type, and the Walker calls each visitor's `enter` / `leave` / `skip` hooks as it reaches a node. New rules and decorators follow this pattern instead of parsing documents by hand. -The full guide, with examples, is in [`.claude/rules/rules-system.md`](./.claude/rules/rules-system.md). +The full guide, with examples, is in [the `rules-system` skill](./.claude/skills/rules-system/SKILL.md). ## Add or change a built-in rule @@ -144,40 +113,11 @@ Naming and reuse: - A `redocly.yaml` in the repository root affects unit tests in the CLI package. Remove it before running them. - Run the full suite (`npm test`) when you touch core linting logic. +- Run `npm run client-generators` when you touch client generation — it is the whole generator suite in one command. -The full testing and QA rules are in +The full testing and QA rules — including the rule test pattern to copy — are in [`.claude/rules/testing.md`](./.claude/rules/testing.md). -The rule test pattern looks like this: - -```ts -import { outdent } from 'outdent'; -import { parseYamlToDocument, replaceSourceWithRef } from '../../../../__tests__/utils.js'; -import { createConfig } from '../../../config/index.js'; -import { lintDocument } from '../../../lint.js'; -import { BaseResolver } from '../../../resolve.js'; - -describe('Oas3 no-my-rule', () => { - it('should report a violation', async () => { - const document = parseYamlToDocument( - outdent` - openapi: 3.0.0 - ... - `, - 'foobar.yaml' - ); - - const results = await lintDocument({ - externalRefResolver: new BaseResolver(), - document, - config: await createConfig({ rules: { 'no-my-rule': 'error' } }), - }); - - expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`...`); - }); -}); -``` - ## Code quality — no AI slop Before opening a PR, strip the things an assistant tends to add that a human reviewer would not: @@ -207,7 +147,7 @@ The full release and commit workflow is in [`.claude/rules/workflow.md`](./.clau - Every feature or fix needs a changeset: run `npx changeset` and describe the change in sentence case. If the change lives in `packages/core` or `packages/respect-core` but affects CLI behavior, include `@redocly/cli` as well. - `@redocly/cli`, `@redocly/openapi-core`, and `@redocly/respect-core` share one version and release together; `@redocly/client-generator` is versioned separately. + All three packages share one version and release together. - Use [Conventional Commits](https://www.conventionalcommits.org/) for commit messages. - Don't add AI co-author or "Generated by" lines to commits. - Don't modify the pull request template. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9351364581..d7101340e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -238,6 +238,28 @@ Note that the snapshot does not always match the command output because of the w This is intentional so outputs stay consistent for snapshot testing. The order of stdout and stderr in a snapshot may differ from what you see in the terminal, but the combined output is stable. +### Generator tests + +Client generation has its own suite: `npm run client-generators` runs the `@redocly/client-generator` unit tests together with the `tests/e2e/generate-client` end-to-end tests, so one command covers everything about generation. + +```bash +npm run client-generators # every generator test +npm run client-generators -- tests/e2e/generate-client/go.test.ts # one file +npm run client-generators -- -t 'gofmt' # by test name +``` + +Those e2e tests compile their output with real toolchains, so what is available decides what runs: + +- **Python** (`python3`, plus `httpx` for the import bars) and **Go** (`go build`, `go vet`, `gofmt`) and **PHP** (`php -l`) — a bar for a missing toolchain skips itself rather than failing, so a partial local setup still gives a useful run. CI installs Python and `httpx`; Go and PHP come with the runner image. +- The largest bars generate from big real-world descriptions (Rebilly, the GitHub REST API), which is why they are slow and why the suite has its own CI job — a growing set of compiled-language bars must not slow the shared e2e job. + +`npm run e2e` covers everything under `tests/e2e/` **except** `generate-client`. +`npm run unit` still includes the client-generator unit tests, so the coverage report stays whole. + +Several of these tests run a local HTTP server and assert on its request log. +On a machine with many cores, vitest runs enough of them in parallel to occasionally reset a connection — a failure that says nothing about the code under test. +Reading a server's log goes through `serverLog()` in `tests/e2e/generate-client/helpers.ts`, which retries for that reason; if you see an isolated `ECONNRESET` or `fetch failed`, re-run the file before investigating. + ### Smoke tests Smokes are for testing the CLI in different environments. diff --git a/docs/@v2/commands/eject-generator.md b/docs/@v2/commands/eject-generator.md new file mode 100644 index 0000000000..4596419700 --- /dev/null +++ b/docs/@v2/commands/eject-generator.md @@ -0,0 +1,113 @@ +# `eject-generator` + +## Introduction + +The `eject-generator` command copies a built-in client generator into your repository as an editable file. +You own the ejected generator and can customize it. +The generated client stays generated and reproducible. +Do not edit it manually. +You or your agent edit the generator, and the `redocly generate-client` command rebuilds the client. +When the spec changes later, the command regenerates the client and keeps your customization. + +You can eject every built-in generator: the SDKs (`typescript`, `python`, `go`, `php`) and the add-on generators (`zod`, `mock`, `cli`, `swr`, `tanstack-query`, `transformers`). +A generator that writes reference documentation carries that page with it, so ejecting `cli` or `python` also hands you the layout of its page. +The `tanstack-query-vue`, `-svelte`, and `-solid` variants are the same generator with one different argument. +Eject `tanstack-query` and set the framework in your copy. + +## Usage + +```bash +redocly eject-generator python +redocly eject-generator zod --dir ./generators +redocly eject-generator php --update +redocly eject-generator php --force +``` + +## Options + +| Option | Type | Description | +| ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| 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`. | +| `--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. | + +## How it works + +The eject operation writes two files: + +- `/.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. + + 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. + 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. + The skill records the decisions that the code implements, and the loop to follow when you change the generator. + First state the change in the skill, then make the code match. + Coding agents load skills automatically, so your agent starts from the design and does not reverse-engineer the code. + +The first eject also writes `.claude/skills/client-generators/SKILL.md`, the shared authoring guide. +The guide describes the generator contract, the API model, and the helper library. +You can edit the skills, in the same way as the generator. +The `--update` option does a three-way merge of your skill edits with the newer version. +A fresh eject or `--force` writes the skills as Redocly ships them. + +In addition to the code, the command also writes a short pointer to the skills into `/AGENTS.md`. +This pointer explains the directory to a reader who has no context. +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. +If the config has no `client.generators` list yet, the command adds one. + +```yaml +client: + generators: + - ./generators/python.mjs +``` + +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. + +## Run the ejected generator + +Generation is the same command as before the eject, because the config now points at your copy: + +```sh +redocly generate-client openapi.yaml --output src/client.ts +``` + +If you did not wire the config, name the file with `--generator`: + +```sh +redocly generate-client openapi.yaml --output src/client.ts --generator ./generators/python.mjs +``` + +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. +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. +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. +The command marks conflicts with standard `<<<<<<<` markers. +Resolve the conflicts manually. + +An ejected generator continues to operate across CLI upgrades if the authoring contract that it was written against stays compatible. +The contract follows the `@redocly/client-generator` version. +A breaking change increases the major version (the minor version, while the package is `0.x`). +A generator ejected from an incompatible version fails before it runs. +The error displays the version that the generator expects, the version that you have, and the `--update` command that aligns them. diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index c9d2471a82..f2f5d41540 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -1,24 +1,62 @@ # `generate-client` {% admonition type="warning" name="Experimental" %} -`generate-client` is an experimental feature: its flags, generated output, configuration schema, and custom-generator API may change in any minor release until it's stable. -We'd love your feedback while we stabilize it. +`generate-client` is an experimental feature. +Its flags, generated output, configuration schema, and custom-generator API can change in any minor release until the feature is stable. +Send us your feedback while we stabilize the feature. {% /admonition %} +## Quickstart + +Point the command at a description and give it an output path: + +```bash +redocly generate-client openapi.yaml --output src/client.ts +``` + +That writes one self-contained file with a typed function for each operation: + +```ts +import { listOrders, createOrder, configure } from './client.js'; + +configure({ auth: { bearer: process.env.API_TOKEN } }); + +const orders = await listOrders({ query: { status: 'open', limit: 10 } }); +const created = await createOrder({ body: { items: [{ menuItemId: 'itm_1', quantity: 2 }] } }); +``` + +The client has no dependencies, and it carries the behavior an API needs: auth for every scheme the description declares, opt-in retries, timeouts, middleware, pagination iterators, and typed server-sent events. +Add a flag for each extra artifact you want: + +```bash +redocly generate-client openapi.yaml -o src/client.ts \ + --generator zod --generator tanstack-query --generator mock --docs +``` + +The rest of this page describes the flags. +[Use the generated client](../guides/use-generated-client.md) describes what the output does. + ## Introduction The `generate-client` command generates a typed TypeScript client from an OpenAPI 3.x description. -Swagger 2.0 descriptions are also accepted and normalized to the 3.x shape before generation. -The description is validated first: unresolved `$ref`s or structural errors fail generation with the problems listed, independent of your lint configuration. +The command also accepts Swagger 2.0 descriptions and normalizes them to the 3.x shape before generation. +The command validates the description first. +If the description has unresolved `$ref`s or structural errors, the command stops the generation and lists the problems. +This validation does not depend on your lint configuration. -The generated client has zero runtime dependencies by default — it uses only web-standard APIs (`fetch`, `AbortController`, `URLSearchParams`), so it runs in browsers, Node, Bun, Deno, and edge runtimes. -By default it emits a single self-contained file with inline types and one async function per operation. +By default, the generated client has zero runtime dependencies. +The client uses only web-standard APIs (`fetch`, `AbortController`, `URLSearchParams`). +Because of this, the client runs in browsers, Node, Bun, Deno, and edge runtimes. +By default, the command writes one self-contained file with inline types and one async function for each operation. -The `` argument is a file path, a URL, or an [`apis:` alias](../configuration/index.md), resolved the same way as in other commands such as `bundle` and `lint`. -An alias, or a path matching an api's `root`, uses that api's `client` block and `clientOutput`; an unmatched path or URL uses the top-level `client` defaults. -With no argument, a client is generated for every api that declares a `client` block or a `clientOutput` (see [`client` configuration](../configuration/reference/client.md)). +The `` argument is a file path, a URL, or an [`apis:` alias](../configuration/index.md). +The command resolves the argument in the same way as other commands, for example `bundle` and `lint`. +An alias, or a path that matches the `root` of an api, uses the `client` block and the `clientOutput` of that api. +An unmatched path or URL uses the top-level `client` defaults. +If you give no argument, the command generates a client for each api that declares a `client` block or a `clientOutput` (see [`client` configuration](../configuration/reference/client.md)). -This page covers running the command; for the generated client's runtime API (auth, error handling, middleware, retries, and the add-on generators), see [Use the generated client](../guides/use-generated-client.md). +This page tells you how to run the command. +For the runtime API of the generated client (auth, error handling, middleware, retries, and the add-on generators), see [Use the generated client](../guides/use-generated-client.md). ## Usage @@ -33,37 +71,42 @@ redocly generate-client [--help] [--version] ## Options -| Option | Type | Description | -| ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `api` | string | OpenAPI description file path, URL, or an `apis:` alias. Omit it to generate for every api that has a `client` block or `clientOutput`. | -| `--output`, `-o` | string | Output path (must end in `.ts`); the entry file in multi-file modes. Defaults to the api's `clientOutput`, else `.client.ts` next to the configuration file. Single-API invocations only. | -| `--output-mode` | string | File layout. See [Choose an output mode](#choose-an-output-mode).
**Possible values:** `single`, `split`. Default value is `single`. | -| `--runtime` | string | Where the client's engine lives. See [Choose a runtime](#choose-a-runtime).
**Possible values:** `inline`, `package`. Default value is `inline`. | -| `--import-ext` | string | Extension in 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 value is `js`. | -| `--generator` | [string] | Generator to run — a built-in name (`tanstack-query` also has `-vue`/`-svelte`/`-solid` variants) or a custom generator's path or package; repeat the flag to run several. Default value is `sdk`. See [Generators](../guides/use-generated-client.md#generators). | -| `--args-style` | string | How operation inputs are passed. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `flat`, `grouped`. Default value is `flat`. | -| `--error-mode` | string | How operations report HTTP errors. See [Error handling](../guides/use-generated-client.md#error-handling).
**Possible values:** `throw`, `result`. Default value is `throw`. | -| `--date-type` | string | Type of `date`/`date-time` fields; pair `Date` with the `transformers` generator.
**Possible values:** `string`, `Date`. Default value is `string`. | -| `--mock-data` | string | Data mode for the `mock` generator.
**Possible values:** `static` (deterministic literals), `faker` (`@faker-js/faker` calls). Default value is `static`. | -| `--mock-seed` | number | Seed for `faker`-mode mocks, for reproducible data. Ignored in `static` mode. | -| `--server-url` | string | Override the server URL included in the client as its default. Accepts an absolute (`https://api.example.com`) or relative (`/v1`) URL. Defaults to `servers[0].url`. The app can also repoint the client at runtime — `createClient({ serverUrl })` or `configure({ serverUrl })`, see [Authentication](../guides/use-generated-client.md#authentication) in the usage guide. | -| `--setup` | string | Path to a publisher setup module that gets included in the client — pre-configure defaults such as the server URL, retries, headers, and middleware, so a published SDK ships with them built in. See [Publisher defaults](../guides/customize-client-generation.md#publisher-defaults). | -| `--config` | string | Specify path to the [configuration file](#generate-from-the-configuration-file). | -| `--help` | boolean | Show help. | -| `--version` | boolean | Show version number. | +| Option | Type | Description | +| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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`. | +| `--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`. | +| `--error-mode` | string | Sets how operations report HTTP errors. See [Error handling](../guides/use-generated-client.md#error-handling).
**Possible values:** `throw`, `result`. Default: `throw`. | +| `--date-type` | string | The type of the `date`/`date-time` fields. If you use `Date`, also use the `transformers` generator.
**Possible values:** `string`, `Date`. Default: `string`. | +| `--mock-data` | string | The data mode for the `mock` generator.
**Possible values:** `static` (deterministic literals), `faker` (`@faker-js/faker` calls). Default: `static`. | +| `--mock-seed` | number | The seed for `faker`-mode mocks. Use it to get reproducible data. The command ignores it in `static` mode. | +| `--server-url` | string | Overrides the default server URL in the client. The option accepts an absolute URL (`https://api.example.com`) or a relative URL (`/v1`). Defaults to `servers[0].url`. The app can also change the server URL at runtime with `createClient({ serverUrl })` or `configure({ serverUrl })`. See [Authentication](../guides/use-generated-client.md#authentication) in the usage guide. | +| `--setup` | string | The path to a publisher setup module that the command includes in the client. Use it to pre-configure defaults, for example the server URL, retries, headers, and middleware. A published SDK then contains these defaults. See [Publisher defaults](../guides/customize-client-generation.md#publisher-defaults). | +| `--docs` | boolean | Also write the reference documentation for what this run generates: one Markdown page for each selected generator that documents itself (the CLI, and each SDK). Default value is `false`. | +| `--go-package` | string | The package clause in the output of the `go` generator. It must be a valid Go package name (lowercase letters, digits, and `_`; it must not start with a digit or be a keyword). Default value is `client`. | +| `--config` | string | Specify the path to the [configuration file](#generate-from-the-configuration-file). | +| `--help` | boolean | Display help. | +| `--version` | boolean | Display version number. | ## Examples ### Generate from the configuration file -Instead of passing flags every time, keep the settings in `redocly.yaml` under a top-level `client` block and per-API `apis..client` / `clientOutput` — see the [`client` configuration reference](../configuration/reference/client.md) for the fields. +You do not have to pass flags each time. +Keep the settings in `redocly.yaml` under a top-level `client` block and per-API `apis..client` / `clientOutput`. +See the [`client` configuration reference](../configuration/reference/client.md) for the fields. CLI flags take precedence over the configuration. -Auto-pagination has no CLI flag; it's declared only as [`client.pagination`](../configuration/reference/client.md#pagination-object) configuration or the `x-redocly-pagination` operation extension. +Auto-pagination has no CLI flag. +Declare it only as the [`client.pagination`](../configuration/reference/client.md#pagination-object) configuration or the `x-redoclyPagination` operation extension. ```yaml client: generators: - - sdk + - typescript apis: cafe: root: ./openapi.yaml @@ -77,7 +120,8 @@ redocly generate-client cafe # just the `cafe` api ### Generate from a file path or URL -An unmatched path or URL uses the top-level `client` defaults; `--output` names the entry file: +An unmatched path or URL uses the top-level `client` defaults. +The `--output` flag names the entry file: ```bash redocly generate-client openapi.yaml --output dist/client.ts @@ -85,10 +129,13 @@ redocly generate-client openapi.yaml --output dist/client.ts ### Choose an output mode -`--output-mode` controls how the client is split across files: +The `--output-mode` flag controls how the command splits the client into files: -- `single` (default) — one file (self-contained with the default `inline` runtime). -- `split` — two files: the schema types and type guards move to a sibling `.schemas.ts`, and the entry file re-exports them, so your imports are the same as in `single`. +- `single` (default): the command writes one file. + The file is self-contained with the default `inline` runtime. +- `split`: the command writes two files. + It puts the schema types and the type guards in a sibling file, `.schemas.ts`. The entry file re-exports them. + Because of this, your imports are the same as in `single`. ```bash redocly generate-client openapi.yaml -o src/api/client.ts --output-mode split @@ -98,18 +145,23 @@ Both modes work with both runtimes. ### Choose a runtime -`--runtime` controls where the client's engine (request building, auth, retries, middleware, SSE) lives: +The `--runtime` flag controls the location of the client engine (request building, auth, retries, middleware, SSE): -- `inline` (default) — the runtime source is embedded in the generated output (only the parts your API needs): self-contained, zero runtime dependencies. -- `package` — the generated file imports the runtime from `@redocly/client-generator` and contains only the types, operation descriptors, and thin call wrappers. +- `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. -Choose `package` when you want engine fixes to arrive via `npm update @redocly/client-generator` with no regeneration; the consuming app must then have that package installed as a regular dependency. -Your application code is identical in both modes. +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). ## Resources -- [Use the generated client](../guides/use-generated-client.md) — the runtime API and the add-on generators. -- [`client` configuration](../configuration/reference/client.md) — the `redocly.yaml` `client` block. -- [Lint command](./lint.md) to validate your API description before generating a client. -- [Bundle command](./bundle.md) to combine a multi-file description into a single input file. +- **[Use the generated client](../guides/use-generated-client.md)** - Learn how to use the client produced by the `generate-client` command +- **[Move an app to a generated client](../guides/migrate-to-generated-client.md)** - Replace a hand-written client, one call site at a time +- **[`client` configuration](../configuration/reference/client.md)** - Explore the settings for the `generate-client` command +- **[Lint command](./lint.md)** - Validate your API description before you generate a client +- **[Bundle command](./bundle.md)** - Combine a multi-file description into one input file diff --git a/docs/@v2/commands/index.md b/docs/@v2/commands/index.md index 3d4107239e..8688db9a70 100644 --- a/docs/@v2/commands/index.md +++ b/docs/@v2/commands/index.md @@ -9,21 +9,22 @@ Documentation commands: - [`preview`](preview.md) Start a local preview of a Redocly project with one of the product NPM packages. - [`translate`](translate.md) Generate translation keys for a Redocly Realm, Reef, or Revel project. - [`eject`](eject.md) Eject and modify components from the core theme in a Redocly Realm, Reef, or Revel project. -- [`build-docs`](build-docs.md) Build API description into an HTML file. +- [`build-docs`](build-docs.md) Build an API description into an HTML file. API management commands: -- [`bundle`](bundle.md) Bundle API description. +- [`bundle`](bundle.md) Bundle an API description. - [`generate-client`](generate-client.md) Generate a typed TypeScript client from an OpenAPI description [experimental feature]. +- [`eject-generator`](eject-generator.md) Copy a built-in client generator into your repository as an editable file [experimental feature]. - [`join`](join.md) Join API descriptions [experimental feature]. - [`score`](score.md) Score an API for integration simplicity and AI agent readiness. -- [`split`](split.md) Split API description into a multi-file structure. +- [`split`](split.md) Split an API description into a multi-file structure. - [`stats`](stats.md) Gather statistics for a document. Linting commands: -- [`lint`](lint.md) Lint API description. -- [`check-config`](check-config.md) Lint Redocly configuration file. +- [`lint`](lint.md) Lint an API description. +- [`check-config`](check-config.md) Lint the Redocly configuration file. Testing commands: @@ -46,11 +47,13 @@ Supporting commands: ## Additional options -There are some parameters supported by all commands: +All commands support these parameters: -`--version` display the current version of `redocly`. +`--version` displays the current version of `redocly`. -`--help` display the command help, or the help for the subcommand if you used one. For example: +`--help` displays the help for the command. +If you used a subcommand, it displays the help for that subcommand. +For example: ```bash npx @redocly/cli@latest lint --help @@ -60,13 +63,15 @@ Try these with any of the other commands. ## Config file -Redocly CLI comes with one primary configuration file (`redocly.yaml`), also known as the Redocly configuration file. -This file defines all of the config options available to you, including the location of your files (for unbundling and bundling), and linting rules (for validation against the OpenAPI Specification). +Redocly CLI has one primary configuration file (`redocly.yaml`), also called the Redocly configuration file. +This file defines all of the configuration options available to you. +These options include the location of your files (for unbundling and bundling) and the linting rules (for validation against the OpenAPI Specification). -The Redocly configuration file must sit in your root directory. -If Redocly CLI finds `redocly.yaml` in the root directory, it uses the options set in that file when executing commands. +The Redocly configuration file must be in your root directory. +If Redocly CLI finds `redocly.yaml` in the root directory, it uses the options set in that file when it executes commands. -You can also specify a config file to most commands using `--config myconfig.yaml` as part of the command. For example: +For most commands, you can also specify a configuration file with `--config myconfig.yaml` as part of the command. +For example: ```bash npx @redocly/cli@latest lint --config redocly-official.yaml openapi.yaml diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index da8a7cebc9..ecfa9b3db5 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -2,80 +2,99 @@ ## Introduction -The `client` configuration provides settings for the [`generate-client`](../../commands/generate-client.md) command. -The block can be used at the root of the configuration file, where it holds defaults, and inside an [API-specific section](./apis.md) (`apis..client`), where it overrides the root block for the specific API. +The `client` configuration contains the settings for the [`generate-client`](../../commands/generate-client.md) command. +You can put the block at the root of the configuration file, where it holds the defaults. +You can also put it inside an [API-specific section](./apis.md) (`apis..client`), where it overrides the root block for that API. The input and output are not part of the `client` block: -- **input** — `apis..root`, or a path or alias passed on the command line. -- **output** — `apis..clientOutput`; when omitted it defaults to `.client.ts` next to the configuration file. - The `--output` flag overrides it for single-API invocations. +- **input** — `apis..root`, or a path or alias that you give on the command line. +- **output** — `apis..clientOutput`. + If you omit it, the default is `.client.ts` next to the configuration file. + The `--output` flag overrides it when you generate one API. ## Options -Each scalar option mirrors the matching CLI flag and shares its default — see the [command options](../../commands/generate-client.md#options) for the full description of each value. -The `pagination` option is config-only — a structured, durable contract that belongs in versioned configuration rather than a shell string. -For runs without a configuration file, declare pagination per operation with the `x-redocly-pagination` extension in the description, or pass `pagination` to the programmatic `generateClient(...)`. - -| Option | Type | Description | -| ---------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `generators` | [string] | Generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` — or its `-vue`/`-svelte`/`-solid` variants — `swr`, `mock`, `transformers`) or a custom generator's path or package name. | -| `outputMode` | string | File layout: `single` or `split`. | -| `runtime` | string | Runtime distribution: `inline` or `package`. | -| `importExt` | string | Extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). | -| `argsStyle` | string | How operation inputs are passed: `flat` or `grouped`. | -| `errorMode` | string | How operations report HTTP errors: `throw` or `result`. | -| `dateType` | string | Type of `date`/`date-time` fields: `string` or `Date`. | -| `mockData` | string | Data mode for the `mock` generator: `static` or `faker`. | -| `mockSeed` | number | Seed for `faker`-mode mocks. | -| `queryKeyPrefix` | string | Leading element for every `tanstack-query` query/mutation key — namespaces the cache when several generated APIs share one QueryClient. Config-only, no flag. | -| `serverUrl` | string | Server URL included in the client as its default; falls back to `servers[0].url`. | -| `setup` | string | Path to a publisher setup module that gets included in the client — pre-configures defaults such as the server URL, retries, headers, and middleware. See [Publisher defaults](../../guides/customize-client-generation.md#publisher-defaults). | -| `pagination` | [Pagination object](#pagination-object) | Declares how the API paginates, so paginated operations gain typed `.pages()`/`.items()` async iterators. | +Each scalar option matches the related CLI flag and has the same default. +See the [command options](../../commands/generate-client.md#options) for the full description of each value. +The `pagination` option is available only in the configuration file. +It is a structured, durable contract that belongs in versioned configuration, not in a shell string. +If you run without a configuration file, declare pagination for each operation with the `x-redoclyPagination` extension in the description. +As an alternative, pass `pagination` to the programmatic `generateClient(...)`. + +| Option | Type | Description | +| ----------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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. | +| `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`. | +| `dateType` | string | The type of `date`/`date-time` fields: `string` or `Date`. Every language applies it: `Date` in TypeScript, `datetime`/`date` in Python, `time.Time`/`Date` in Go, `DateTimeImmutable` in PHP. | +| `mockData` | string | The data mode for the `mock` generator: `static` or `faker`. | +| `mockSeed` | number | The seed for mocks in `faker` mode. | +| `queryKeyPrefix` | string | The first element of every `tanstack-query` query key and mutation key. It separates the cache entries when several generated APIs share one QueryClient. This option is available only in the configuration file and has no flag. | +| `codeSamples` | boolean | Emit `.code-samples.yaml` next to the client. This file is an OpenAPI Overlay that adds `x-codeSamples` to each operation. The samples come from each selected generator that implements `sample()`. This option is available only in the configuration file and has no flag. | +| `serverUrl` | string | The server URL that the client includes as its default. If you do not set it, the client uses `servers[0].url`. | +| `goPackage` | string | The package clause for the output of the `go` generator. The value must be a valid Go package name: lowercase letters, digits, and `_`, with no digit at the start, and not a keyword. An invalid value stops generation, so the generator does not emit a file that Go cannot compile. Default: `client`. | +| `cliOutput` | string | The path of a composed CLI entry. The entry includes every api that emits a cli module: from the `cli` generator by name, ejected, or included as a prerequisite. The result is one binary. You address each api by its alias, and each api has `__*` credential variables. This option is available only in the top-level `client` block. See [Compose and extend the CLI](../../guides/use-generated-client.md#compose-and-extend-the-cli). | +| `options` | object | Options for each generator, keyed by generator name. The command validates each entry against the schema that the generator declares. The `python` generator accepts `models`: `dataclass` (default) or `pydantic`. See [Custom generators](../../guides/customize-client-generation.md#custom-generators). | +| `docs` | boolean | Also write the reference documentation for what the run generates: one Markdown page for each selected generator that documents itself (`.cli.md`, `.python.md`, and so on). The `--docs` flag sets it too. Default `false`. | +| `docsFrontmatter` | boolean | Emit YAML front matter carrying the title above each documentation page, for docs sites that expect it. This option is available only in the configuration file. Default `false`. | +| `setup` | string | The path to a publisher setup module that the client includes. The module sets defaults such as the server URL, retries, headers, and middleware. See [Publisher defaults](../../guides/customize-client-generation.md#publisher-defaults). | +| `pagination` | [Pagination object](#pagination-object) | Declares how the API paginates. Paginated operations then get typed `.pages()`/`.items()` async iterators. | ### Pagination object -The `pagination` block is an optional convention rule (the rule fields below, applied to every operation it structurally fits when `style` is set), plus per-operation `operations` overrides and an `exclude` list. -See [Pagination in the usage guide](../../guides/use-generated-client.md#pagination) for how the generated iterators behave. - -| Option | Type | Description | -| ------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `style` | string | How the iterator advances: `cursor` (follow a response cursor), `offset` (advance an offset by each page's item count), `page` (increment a page number), or `link` (follow the response's RFC 8288 `Link` header `rel="next"` — no advance parameter; a convention rule fits only operations whose response documents a `Link` header). | -| `cursorParam` | string | The query parameter that receives the cursor. **REQUIRED** for the `cursor` style. | -| `nextCursor` | string | JSON pointer (RFC 6901, starts with `/`) to the next cursor in the response. **REQUIRED** for the `cursor` style. | -| `hasMore` | string | Optional (`cursor` style): JSON pointer to a boolean "more pages" flag — iteration stops when it resolves to `false`, for APIs whose cursor stays non-null on the last page. | -| `offsetParam` | string | The query parameter the iterator advances. **REQUIRED** for the `offset` and `page` styles. | -| `limitParam` | string | Optional page-size query parameter for any style; recorded for tooling — the iterator never sets it. | -| `items` | string | **REQUIRED**. JSON pointer to the page's item array in the response; use `''` when the response body is the item array itself. | -| `exclude` | [string] | operationIds that no source may paginate; wins over overrides, extensions, and the convention. | -| `operations` | map of operationId → rule | Per-operation rules taking the same fields as the convention; each entry beats the description's `x-redocly-pagination` and the convention. | - -The rules are verified at generate time: the advance parameter must be a declared query parameter of the right type (string for `cursor`, numeric for `offset` and `page`), and the JSON pointers must resolve in the operation's JSON success-response schema, with `items` landing on an array and `hasMore` on a boolean. -A convention that doesn't fit an operation skips it; an explicit rule that doesn't fit fails generation. -The `x-redocly-pagination` operation extension in the API description takes the same rule fields. -Per operation, precedence is `operations[id]`, then `x-redocly-pagination`, then the convention. +The `pagination` block is an optional convention rule, plus `operations` overrides for single operations and an `exclude` list. +The convention rule uses the rule fields below. +When you set `style`, the rule applies to each operation that it structurally fits. +See [Pagination in the usage guide](../../guides/use-generated-client.md#pagination) to learn how the generated iterators behave. + +| Option | Type | Description | +| ------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `style` | string | How the iterator advances: `cursor` (follow a response cursor), `offset` (advance an offset by the item count of each page), `page` (increment a page number), or `link` (follow the RFC 8288 `Link` header `rel="next"` in the response). The `link` style has no advance parameter. As a convention rule, `link` fits only the operations whose response documents a `Link` header. | +| `cursorParam` | string | The query parameter that receives the cursor. **REQUIRED** for the `cursor` style. | +| `nextCursor` | string | The JSON pointer (RFC 6901, starts with `/`) to the next cursor in the response. **REQUIRED** for the `cursor` style. | +| `hasMore` | string | Optional (`cursor` style): the JSON pointer to a boolean "more pages" flag. Iteration stops when the flag resolves to `false`. Use it for APIs whose cursor stays non-null on the last page. | +| `offsetParam` | string | The query parameter that the iterator advances. **REQUIRED** for the `offset` and `page` styles. | +| `limitParam` | string | Optional: the page-size query parameter for any style. The generator records it for tooling. The iterator never sets it. | +| `items` | string | **REQUIRED**. The JSON pointer to the item array of the page in the response. Use `''` if the response body is the item array itself. | +| `exclude` | [string] | The operationIds that no source may paginate. This list wins over overrides, extensions, and the convention. | +| `operations` | map of operationId → rule | Rules for single operations, with the same fields as the convention. Each entry overrides the `x-redoclyPagination` extension in the description and the convention. | + +The generator verifies the rules at generate time. +The advance parameter must be a declared query parameter of the correct type: string for `cursor`, numeric for `offset` and `page`. +The JSON pointers must resolve in the JSON success-response schema of the operation. +The `items` pointer must point to an array, and the `hasMore` pointer must point to a boolean. +If the convention does not fit an operation, the generator skips that operation. +If an explicit rule does not fit, generation fails. +The `x-redoclyPagination` operation extension in the API description uses the same rule fields. +For each operation, the precedence is `operations[id]`, then `x-redoclyPagination`, then the convention. ## Examples ### Configure defaults with a per-API override -An API with its own `client` block uses that block in place of the top-level one; the top-level block applies to APIs without one. -A file-path invocation matching no `apis:` entry uses the top-level `client`, and CLI flags take precedence over the resolved configuration. +An API with its own `client` block uses that block instead of the top-level block. +The top-level block applies to APIs without their own block. +A file-path invocation that matches no `apis:` entry uses the top-level `client`. +CLI flags override the resolved configuration. ```yaml client: generators: - - sdk - argsStyle: flat + - typescript + argsStyle: grouped apis: cafe: root: ./openapi.yaml clientOutput: ./src/api/client.ts client: # replaces the top-level block for this API generators: - - sdk + - typescript - zod - argsStyle: grouped + argsStyle: flat orders: root: ./orders.yaml # no client block — uses the top-level one clientOutput: ./src/api/orders.client.ts @@ -83,7 +102,7 @@ apis: ### Declare pagination -Declare the convention once, with per-operation overrides and exclusions: +Declare the convention one time, with overrides and exclusions for single operations: ```yaml client: @@ -101,13 +120,14 @@ client: items: /data ``` -For code-level control — including registering [custom generators](../../guides/customize-client-generation.md#custom-generators) inline — use the programmatic `generateClient(...)` API instead. +For code-level control, use the programmatic `generateClient(...)` API instead. +With this API, you can also register [custom generators](../../guides/customize-client-generation.md#custom-generators) inline. ## Related options -- [apis](./apis.md) settings define each API's root document, output, and per-API overrides. +- The [apis](./apis.md) settings define the root document, the output, and the overrides for each API. ## Resources -- [`generate-client` command](../../commands/generate-client.md) — flags, output modes, and invocation. -- [Use the generated client](../../guides/use-generated-client.md) — the runtime API (auth, retries, middleware, extra generators). +- **[`generate-client` command](../../commands/generate-client.md)** - Learn about the the `generate-client` command's flags, output modes, and invocation +- **[Use the generated client](../../guides/use-generated-client.md)** - Learn how to use the client produced by the `generate-client` command diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 9f49f942a9..694cb70932 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -1,15 +1,21 @@ # Customize client generation -How to shape what [`generate-client`](../commands/generate-client.md) produces — pre-configured publisher defaults and custom generators. -This page is for the person who **runs the generator** (an SDK publisher, a platform team); for consuming the generated client, see [Use the generated client](./use-generated-client.md). +Learn how to control the output of [`generate-client`](../commands/generate-client.md). +It covers pre-configured publisher defaults and custom generators. +This page is for the person who **runs the generator**, for example an SDK publisher or a platform team. +To use the generated client, see [Use the generated client](./use-generated-client.md). ## Publisher defaults -Middleware and configuration are normally composed by the [consumer](./use-generated-client.md#middleware). -If you **publish an SDK** you can pre-configure the client at generation time with `--setup `: defaults such as the server URL, retries, headers, and middleware are included in the generated client, so the SDK ships with them built in. -Setup changes the client's built-in _behavior_; it emits no extra file — to derive additional artifacts from the description, use [generators](./use-generated-client.md#generators) instead. +The [consumer](./use-generated-client.md#middleware) normally composes middleware and configuration. +If you **publish an SDK**, you can pre-configure the client at generation time with `--setup `. +The generated client then includes defaults such as the server URL, retries, headers, and middleware. +The SDK includes these defaults when you publish it. +Setup changes the client's built-in _behavior_ and writes no extra file. +To make more artifacts from the description, use [generators](./use-generated-client.md#generators) instead. -A setup module is a plain file that default-exports a `{ config, middleware }` object — no imports required: +A setup module is a plain file with a default export of a `{ config, middleware }` object. +It does not need imports: ```ts // client-setup.ts @@ -29,9 +35,14 @@ export default { redocly generate-client openapi.yaml --output src/api/client.ts --setup ./client-setup.ts ``` -Inclusion is a generation-time transform: only the setup expression lands in the client, so an `inline` client stays zero-dependency, and the included block is typed against the client's own contract in the generated file — a shape mistake fails the consumer's `tsc`. +Inclusion is a generation-time transform. +Only the setup expression goes into the client, so an `inline` client keeps zero dependencies. +The generated file types the included block against the client's own contract. +A shape mistake causes an error in the consumer's `tsc`. -For editor autocomplete while authoring, optionally wrap the object in `defineClientSetup` — a typing-only helper, stripped at generation time, identical in both runtimes: +To get editor autocomplete when you write the setup, you can wrap the object in `defineClientSetup`. +This helper only supplies types, and generation removes it. +The helper is identical in both runtimes: ```ts // client-setup.ts — the same setup, typed while editing @@ -50,68 +61,181 @@ export default defineClientSetup({ ``` The pre-configured block runs before the consumer's own setup. -**Config values** layer lowest to highest — later always wins, so a consumer overrides a pre-configured default: +**Config values** apply in layers, from lowest to highest. +A later value always wins, so a consumer overrides a pre-configured default: 1. The description's defaults (for example `servers[0].url`). 2. The publisher setup. 3. The app's `configure()`. -**Middleware composes** instead (publisher middleware first, then the consumer's). -Express un-bypassable behavior as middleware, not a custom `fetch`. -A setup file may import **only** from `@redocly/client-generator`. +**Middleware composes** instead: the publisher middleware runs first, then the consumer's middleware. +To make a behavior that the consumer cannot bypass, use middleware, not a custom `fetch`. +A setup file can import **only** from `@redocly/client-generator`. See the [`baked-setup` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/baked-setup). +## Eject + +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. +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. +[`--update`](../commands/eject-generator.md#update-an-ejected-generator) merges later built-in versions into your copy. + +The eject command also writes the generator's design as an agent skill (`.claude/skills/-generator/SKILL.md`). +It writes the shared authoring skill too. +Your agent uses the design as the source of truth. +First, state the change in the design. +Then make the code agree with the design. +Do not edit the generated output by hand; edit only the generator. + ## Custom generators The built-in generators cover common targets. -For anything else derived from the same description (validators in another library, a permissions map, a house-style SDK), write a **custom generator**: it reads the same API model the built-ins consume, so its output never drifts from the description. -A generator adds artifacts _next to_ the client — it doesn't change the generated client's behavior; for that, use [publisher defaults](#publisher-defaults) or let the consumer compose [middleware](./use-generated-client.md#middleware). +For other artifacts from the same description, write a **custom generator**. +Examples are validators in another library, a permissions map, or an SDK in your house style. +A custom generator reads the same API model as the built-in generators. +Because of this, its output always agrees with the description. -A generator is `{ name, run }` (plus optional compatibility metadata); author it with `defineGenerator` from the package root, and build real TypeScript with the emit toolkit from `@redocly/client-generator/generate` — the same `ts.factory` + printer the built-in generators use, so the schema→type mapping matches the sdk's exactly: +A generator adds artifacts _next to_ the client. +It does not change the behavior of the generated client. +To change the behavior, use [publisher defaults](#publisher-defaults) or let the consumer compose [middleware](./use-generated-client.md#middleware). -```ts -// response-map-generator.ts -import { defineGenerator } from '@redocly/client-generator'; -import { printStatements, schemaToTypeNode, ts } from '@redocly/client-generator/generate'; +A generator is a `{ name, run }` object, with optional compatibility metadata. +Write it with `defineGenerator` from the package root. +The output is text, so a generator can emit **any language**. +Examples are Python models, a Go client, or a permissions matrix. +Emitted file paths must stay inside the `--output` directory. +Subdirectories are permitted, but the CLI rejects paths that escape the directory. + +**Compatibility follows the `@redocly/client-generator` version.** +The API model and the helper library are the generator contract, and the contract changes under semver. +A breaking change increases the major version (the minor version, while the package is `0.x`). +Declare the version that you wrote against with `requiresGenerator: '^1.2.0'`. +An incompatible CLI then fails immediately and does not give your generator a model shape it does not expect. +The error names the version the CLI has, the version you need, and the upgrade. + +Ejected generators record the version for you. +The accepted range forms are `^1.2.0`, `~1.2.0`, `>=1.2.0`, and an exact `1.2.0`. +The CLI rejects other forms as unreadable and does not guess. + +If you omit `requiresGenerator`, the CLI assumes the current version. +This is acceptable while you iterate. + +Set the version before the generator stays in use longer than the CLI it was written for. +Examples are a shared repository, a published package, and output that CI regenerates. +Without the version, a changed model shape causes incorrect output, not an error. -const { factory } = ts; +A generator can declare its own options with a JSON Schema. +Publishers then configure it in the same way as the built-in generators: +```js export default defineGenerator({ + name: 'permissions-matrix', + // The toolkit version this was written against. Declare it from the start: a generator + // usually outlives the CLI version it was written for, and without it a model change + // surfaces as odd output far from its cause. + requiresGenerator: '^1.2.0', + options: { + type: 'object', + properties: { groupBy: { enum: ['tag', 'path'], default: 'tag' } }, + additionalProperties: false, + }, + run({ model, outputPath, options }) { + // `options` is validated against the schema before `run` is called. + }, +}); +``` + +```yaml +client: + generators: + - ./tools/permissions-matrix.mjs + options: + permissions-matrix: + groupBy: path +``` + +The schema covers what configuration needs, not all of JSON Schema. +It permits a top-level `type: 'object'` with `properties`, `required`, and `additionalProperties`. +Each property is a scalar (`string`, `number`, `boolean`), an `enum`, or an array of scalars (`{ type: 'array', items: { type: 'string' } }`). +Each property can have a `default` and a `description`. + +Validation runs one time per generator before the CLI writes any file. +An unknown key, a value of an incorrect type, a value outside an `enum`, or a missing `required` key stops generation. +The error displays the generator's name and the incorrect key. +The CLI rejects unknown keys unless the schema sets `additionalProperties: true`. +`run` receives `options` with the defaults applied, so a generator reads its options without more checks. + +If you set `options` for a selected generator that declares no schema, the CLI displays a warning. +Without the warning, the CLI would ignore the entry with no message. + +### Language-neutral helpers + +The package root exports pure helpers over the API model. +The helpers cover the points where output languages differ. +Because of this, a generator in any output language does not implement schema semantics again: + +| Helper | Use | +| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `flattenAllOf(schema, model)` | Gives the merged property view of `allOf` compositions. Languages without intersection types render this. | +| `discriminatorCases(schema, model)` | Gives a `{ property, cases }` dispatch table for discriminated unions. Each language renders its own form: a sealed hierarchy, a type switch, or a `Union`. | +| `isNullable(schema)` / `unwrapNullable(schema)` | Finds and removes `null` union members (`Optional[T]`, pointers, `Option`). | +| `enumValues(schema)` | Gives the values plus SCREAMING_SNAKE member-name suggestions. | +| `casing` / `identifierFor(name, opts)` | Gives camel/pascal/snake/screaming casing and keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` are included, or pass your own set). | +| `Printer` | A text builder that manages indentation. You do not manage whitespace manually. | +| `docText(description)` | Gives the description text as trimmed lines for any comment syntax. | +| `schemaAtPointer(schema, pointer, model)` | Resolves an RFC 6901 JSON pointer over a schema, through refs and `allOf`. Example: a pagination `items` pointer to its element type. | +| `paginationRuleFor(op, config)` | Gives the normalized pagination rule that applies to an operation (per-op config > `x-redoclyPagination` > fitting convention). | +| `NotSupportedError` | Throw it to reject an option that the generator cannot obey. The CLI prints the message as a user error, not a crash. | + +These helpers plus `Printer` are the ONE way to write a generator, in any output language. +No part of the authoring path depends on the `typescript` package. +Because of this, a generator also runs in the browser or in another embedded host. +Only one step of `generate-client` parses TypeScript: the step that bakes a `--setup` module. +For this reason, `typescript` is an optional peer dependency. +Install it if you use that flag, and do not install it otherwise. + +`redocly eject-generator ` writes this guidance into your repository as an agent skill. +Your coding agent then has the contract, the model reference, and this helper table without instructions from you. + +### TypeScript artifacts + +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. +`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: + +```js +import { tsType } from '@redocly/client-generator/generate'; + +export default { name: 'response-map', - requires: ['sdk'], + requires: ['typescript'], run({ model, outputPath }) { - // One `ResponseShapes` entry per operation with a JSON success body. const members = model.services .flatMap((service) => service.operations) .flatMap((op) => { const success = op.successResponses.find((r) => r.contentType.includes('json')); - if (!success) return []; - return [ - factory.createPropertySignature( - undefined, - op.name, - undefined, - schemaToTypeNode(success.schema) - ), - ]; + return success ? [` ${op.name}: ${tsType(success.schema, 'string', ' ')};`] : []; }); - const alias = factory.createTypeAliasDeclaration( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - 'ResponseShapes', - undefined, - factory.createTypeLiteralNode(members) - ); return [ - { path: outputPath.replace(/\.ts$/, '.responses.ts'), content: printStatements([alias]) }, + { + path: outputPath.replace(/\.ts$/, '.responses.ts'), + content: `export type ResponseShapes = {\n${members.join('\n')}\n};\n`, + }, ]; }, -}); +}; ``` -The toolkit exports `ts`, `printStatements`, `parseStatements`, `operationSignature`, `schemaToTypeNode`, `pascalCase`, and more; the package root exports the model (IR) types. -For a trivial artifact, returning a plain string as `content` works too — no toolkit required. +The `@redocly/client-generator/generate` entry exports `tsType`, `tsJsdoc`, `codeLiteral`, `operationSignature`, and `pascalCase`. +The package root exports the model (IR) types and the neutral helpers. +For a simple artifact, you can also return a plain string as `content`. -Select a generator in `redocly.yaml` by path or package name: +Select a generator in `redocly.yaml` by path or by package name: ```yaml apis: @@ -120,12 +244,12 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript - ./tools/response-map-generator.ts # local path (resolved against redocly.yaml) - '@acme/openapi-valibot' # published package ``` -Or register one **inline** with the programmatic API and select it by name: +Or register a generator **inline** with the programmatic API and select it by name: ```ts import { generateClient } from '@redocly/client-generator'; @@ -135,15 +259,106 @@ await generateClient({ api: './openapi.yaml', output: './src/api/client.ts', customGenerators: [responseMap], - generators: ['sdk', 'response-map'], + generators: ['typescript', 'response-map'], }); ``` -Import-specifier generators execute at generation time — they carry the same trust level as any installed dependency you run. -See the [`ast-toolkit-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/ast-toolkit-generator) for the runnable toolkit-based plugin (including type-importing referenced schemas), the [`custom-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/custom-generator) for a minimal string-building one, and the [`nested-facade` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/nested-facade) for a realistic one that derives an `api..` facade from the description's tags. +### Code samples for docs + +A generator that can call an operation can also document the operation. +Implement the optional `sample(operation, ctx)` hook to return one idiomatic snippet (`{ lang, label, source }`) for each operation. +With `codeSamples: true` in the `client` block, generation collects the samples of every selected generator into `.code-samples.yaml`. +This file is an [OpenAPI Overlay](https://spec.openapis.org/overlay/latest.html) that adds `x-codeSamples` to each operation. +Docs tooling can apply the file. + +The built-in `typescript` generator implements the hook. +If you only set the flag, your Redoc docs get a TypeScript example for each operation. +These examples always agree with the SDK. + +Do not read the built-in TypeScript generators as a model for your own. +Each one is a short entry over renderers that are internal to the package, so you cannot import the parts it uses. +The runnable examples at the end of this page are the model to copy. +They use only the public toolkit. + +### Reference documentation for what you generate + +Implement the optional `docs(input)` hook to return the reference page for your output, with the same `{ path, content }` shape as `run`. +The command calls it only when `client.docs` (or `--docs`) is on, so documentation is one switch for the whole run. + +A generator documents itself, because nothing else knows its call syntax. +The `renderReferencePage(model, options)` helper renders the standard page, and it takes your `sample` hook for the snippets: + +```js +import { defineGenerator, renderReferencePage } from '@redocly/client-generator'; + +const rubyCall = (operation) => ({ lang: 'ruby', source: `client.${operation.name}` }); + +export default defineGenerator({ + name: 'ruby', + run({ model, outputPath }) { + /* the SDK */ + }, + sample: rubyCall, + docs({ model, outputPath, emit }) { + return [ + { + path: outputPath.replace(/\.[^.\\/]+$/, '.ruby.md'), + content: renderReferencePage(model, { + title: `${model.title} Ruby SDK reference`, + frontmatter: emit.docsFrontmatter === true, + language: { + name: 'ruby', + label: 'Ruby', + fence: 'ruby', + requires: 'The SDK needs `faraday`.', + }, + sample: rubyCall, + pagination: emit.pagination, + }), + }, + ]; + }, +}); +``` + +Write your own page instead if the standard layout does not fit: the hook returns files, so the content is yours. +An ejected generator keeps its `docs` hook, so the page layout is ejectable with the generator that owns it. + +### Recipes + +The built-in generators cover the common targets, and a custom generator covers the rest. +These are the shapes people ask for most often, each a file you copy rather than a product to wait for. + +**A schema library the built-ins do not cover.** +The built-in `zod` generator emits Zod schemas. +For another library, walk `model.schemas` and print the expression that library expects. +`flattenAllOf` merges `allOf` compositions into one property list, `enumValues` returns the values of an enum, and `metadata.format` tells you when a string is really binary content. +The [`valibot-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/valibot-generator) does this in about 60 lines, and it type-checks against the real library in our CI. + +**A framework wrapper.** +The built-in `tanstack-query` and `swr` generators forward to the client's operation functions. +A wrapper for another framework is the same job: read the operations, emit one function or hook per operation, and forward to the generated call. +Declare `requires: ['typescript']` so the client it wraps is always there, and `errorModes: ['throw']` if the wrapper expects a thrown error. + +**A shape your codebase already uses.** +A resource facade, a permissions matrix, a route map, a fixtures file. +The [`nested-facade`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/nested-facade) and [`custom-generator`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/custom-generator) examples are two of these. + +**A change to a built-in generator, not a new one.** +Start from its code instead of a blank file: `redocly eject-generator ` writes the built-in into your repository, with its design as an agent skill, and an unmodified copy produces byte-identical output. +This is the shorter path whenever your requirement is "the built-in output, but different". + +Every one of these runs in the same pass as the built-ins, reads the same API model, and adds no dependency to the generated client. + +Import-specifier generators execute at generation time. +They have the same trust level as any installed dependency that you run. ## Resources -- [`generate-client` command](../commands/generate-client.md) — flags, output modes, and invocation. -- [`client` configuration](../configuration/reference/client.md) — the `redocly.yaml` `client` block. -- [Use the generated client](./use-generated-client.md) — the consumer-side guide. +- **[`valibot-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/valibot-generator)** - Copy a ~60-line generator that emits schemas for a validation library the built-ins do not cover +- **[`typescript-types-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/typescript-types-generator)** - Learn how to use the runnable plugin based on `tsType` and how to type-import referenced schemas +- **[`custom-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/custom-generator)** - An example of minimal generator that builds strings +- **[`nested-facade` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/nested-facade)** - An example of a realistic generator that derives an `api..` facade from the description's tags. +- **[`generate-client` command](../commands/generate-client.md)** - flags, output modes, and invocation +- **[`client` configuration](../configuration/reference/client.md)** - Learn about the the `generate-client` command's flags, output modes, and invocation +- **[Use the generated client](./use-generated-client.md)** - Learn how to use the client produced by the `generate-client` command diff --git a/docs/@v2/guides/migrate-to-generated-client.md b/docs/@v2/guides/migrate-to-generated-client.md new file mode 100644 index 0000000000..67dc4ef585 --- /dev/null +++ b/docs/@v2/guides/migrate-to-generated-client.md @@ -0,0 +1,125 @@ +# Move an existing app to a generated client + +## Introduction + +Most applications already talk to their API through code somebody wrote by hand: a types file, a `fetch` wrapper, and a set of helpers around them. +This guide tells you how to replace that code with a generated client, one API at a time, without a rewrite. + +It assumes you have an OpenAPI description of the API. +If the description is out of date, read [Expect the description to be wrong](#expect-the-description-to-be-wrong) first, because that step decides how the rest of the work feels. + +## Generate beside your current client + +Generate into a new path and change nothing else: + +```bash +redocly generate-client openapi.yaml --output src/api/generated/client.ts +``` + +Your application still runs on the old code. +You now have both, so you can compare them and migrate one call site at a time. + +Put the command in your build so the client cannot drift from the description: + +```json +{ + "scripts": { + "generate": "redocly generate-client openapi.yaml -o src/api/generated/client.ts", + "build": "npm run generate && tsc" + } +} +``` + +Commit the generated file. +A reviewer then sees what changed in the API when you regenerate, and the build does not depend on the description being reachable. + +## Map your old client onto the new one + +The pieces of a hand-written client have direct equivalents: + +| What you have now | What replaces it | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| A types file, generated or hand-written | The types in the generated client. Every operation carries its own request and response types. | +| A `fetch` wrapper with a base URL | `configure({ serverUrl })`, or the `servers` entry of the description. | +| Auth headers added by hand | `configure({ auth: … })`, or `client.auth.bearer(…)` on one instance. See [Authentication](./use-generated-client.md#authentication). | +| A retry helper | `configure({ retry: { retries: 3 } })`. See [Retries](./use-generated-client.md#retries). | +| A hand-rolled pagination loop | Declared [pagination](./use-generated-client.md#pagination) with the `.pages()` and `.items()` iterators. | +| Interceptors for logs, traces, or headers | [Middleware](./use-generated-client.md#middleware), which sees each operation's id and tags as literal types. | +| An existing configured request library | `configure({ fetch })`. See [The HTTP layer](./use-generated-client.md#the-http-layer). | +| Response shapes checked by hand | The [`zod` generator](./use-generated-client.md#runtime-validation) and its `zodValidation()` middleware. | +| Hand-written API mocks in tests | The [`mock` generator](./use-generated-client.md#generators): MSW handlers and typed data factories. | + +Two of those replace whole files rather than lines. +Pagination loops and mock fixtures are usually the largest deletions in a migration of this kind. + +## Migrate the call sites + +Work per module, not per operation. +For each module, change the imports to the generated client and let the compiler list what breaks: + +```ts +// Before +import { getOrder } from '../api/orders'; +const order = await getOrder(orderId); + +// After +import { getOrderById } from '../api/generated/client.js'; +const order = await getOrderById({ path: { orderId } }); +``` + +Three differences account for most of the compiler errors: + +- **Operation names come from the description.** The generated name is the `operationId`, so `getOrder` becomes whatever the description calls it. + If the names read badly, fix them in the description: every consumer improves at once. +- **Inputs have named slots.** Query parameters go in `params`, the body in `body`, headers in `headers`. + A call that passes an undeclared key fails with a `TypeError` that names the key, so a wrong call cannot reach the network. + [`--args-style grouped`](../commands/generate-client.md#options) puts every input in one object, which reads better at large call sites. +- **Errors are typed.** By default an operation throws `ApiError` on a non-2xx response. + With [`--error-mode result`](./use-generated-client.md#error-handling) it returns `{ data, error, response }` instead, which is closer to some hand-written wrappers. + +## Expect the description to be wrong + +A generated client holds your code to the description, so the first run tells you where the two disagree. +This is the useful part of the migration, and it is also the part that surprises people, so plan for it. + +Turn on runtime validation early: + +```ts +import { use } from './api/generated/client.ts'; +import { zodValidation } from './api/generated/client.zod.ts'; + +use(zodValidation()); // invalid requests throw; response drift warns +``` + +Requests that do not match the description throw before the network call, and responses that do not match warn by default. +Both point at the field and the operation. + +When a check fails, fix the cause rather than the check. +A failure is either a defect in your code or a defect in the description, and disabling validation keeps both. +Correct the description, regenerate, and every consumer of that API gets the correction. + +## Migrate the tests too + +A generated client that every test mocks away is a generated client that no test exercises. +The `mock` generator emits MSW handlers and typed factories, so a test can run the real client against a fake network: + +```ts +import { listOrdersHandler, createOrder } from './api/generated/client.mocks.ts'; + +server.use(listOrdersHandler({ orders: [createOrder({ id: 'ord_1' })] })); +// the code under test now issues a real request through the real client +``` + +This moves argument building, URL construction, and response parsing into the test, which is where the migration's remaining defects usually hide. + +## Delete the old client + +Remove the old module when its last call site is gone, and keep the deletion in its own commit. +The generated client replaces code rather than adding a layer, so the net line count of a migration is usually negative. + +## Resources + +- [`generate-client` command](../commands/generate-client.md): the flags and the invocation. +- [Use the generated client](./use-generated-client.md): auth, retries, middleware, pagination, and the add-on generators. +- [Customize client generation](./customize-client-generation.md): publisher defaults, custom generators, and ejecting a built-in generator. +- [`client` configuration](../configuration/reference/client.md): the `redocly.yaml` block. diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index bdb88aad12..c878e578bf 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -1,36 +1,417 @@ # Use the generated client -How to consume the TypeScript client produced by [`generate-client`](../commands/generate-client.md): authentication, argument styles, error handling, middleware, retries, and the optional add-on generators. -For invoking the command itself (flags, output modes, config), see the [`generate-client` command reference](../commands/generate-client.md). -To shape what gets generated — publisher defaults, custom generators — see [Customize client generation](./customize-client-generation.md). +This guide tells you how to use the TypeScript client that [`generate-client`](../commands/generate-client.md) produces. +It covers authentication, argument styles, error handling, middleware, retries, and the optional add-on generators. +For the command itself (flags, output modes, config), see the [`generate-client` command reference](../commands/generate-client.md). +To change what the command generates (publisher defaults, custom generators), see [Customize client generation](./customize-client-generation.md). ## Generators -`--generator` selects what to emit (default `sdk`). -Each non-`sdk` generator adds a standalone sibling module next to the client; the client itself never imports it, so an add-on never adds a dependency to the client. -Incompatible selections fail fast with an explanation. - -| Generator | Emits | App peer dependency | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | -| `sdk` | The typed client (default). | none | -| `zod` | `.zod.ts` — [Zod](https://zod.dev) schemas + [validation middleware](#runtime-validation). | `zod` `^3.23 \|\| ^4` | -| `tanstack-query` | `.tanstack.ts` — [TanStack Query](https://tanstack.com/query) v5 [factories](#tanstack-query-factories), including `InfiniteOptions` for paginated operations. React by default; `tanstack-query-vue`/`-svelte`/`-solid` switch the adapter import. | `@tanstack/-query` `^5` | -| `swr` | `.swr.ts` — [SWR](https://swr.vercel.app) hooks. | `swr` `^2` | -| `mock` | `.mocks.ts` — [MSW](https://mswjs.io) v2 handlers + `create` factories. | `msw` `^2` (+ `@faker-js/faker` for `--mock-data faker`) | -| `transformers` | `.transformers.ts` — `transform` functions that parse wire dates to `Date`. | none | +The `--generator` option selects the output (default `typescript`). +Each non-`typescript` generator adds a standalone module next to the client. +The client never imports this module. +Because of this, an add-on never adds a dependency to the client. +Incompatible selections fail immediately with an explanation. + +| Generator | Emits | App peer dependency | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| `typescript` | The typed client (default). | none | +| `zod` | `.zod.ts`: [Zod](https://zod.dev) schemas and [validation middleware](#runtime-validation). | `zod` `^3.23 \|\| ^4` | +| `tanstack-query` | `.tanstack.ts`: [TanStack Query](https://tanstack.com/query) v5 [factories](#tanstack-query-factories), with `InfiniteOptions` for paginated operations. React by default; `tanstack-query-vue`/`-svelte`/`-solid` change the adapter import. | `@tanstack/-query` `^5` | +| `swr` | `.swr.ts`: [SWR](https://swr.vercel.app) hooks. | `swr` `^2` | +| `mock` | `.mocks.ts`: [MSW](https://mswjs.io) v2 handlers and `create` factories. | `msw` `^2` (+ `@faker-js/faker` for `--mock-data faker`) | +| `transformers` | `.transformers.ts`: `transform` functions that parse wire dates to `Date`. | none | +| `cli` | `.cli.ts`: a [command-line interface](#generated-cli) for the client, ready to use as a bin. It has typed flags, `--json` bodies, env auth, and `--page-all`. | none | ```sh -redocly generate-client openapi.yaml --output src/client.ts --generator sdk --generator zod --generator mock +redocly generate-client openapi.yaml --output src/client.ts --generator typescript --generator zod --generator mock ``` -`tanstack-query` and `swr` wrap the throw-mode `sdk` functions, so they require `--error-mode throw`; `transformers` requires `--date-type Date`. +`tanstack-query`, `swr`, and `cli` wrap the throw-mode `typescript` client. +Because of this, they require `--error-mode throw`. +The `transformers` generator requires `--date-type Date`. See the [`zod`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/zod), [`tanstack-query`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/tanstack-query), and [`mock`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/mock) examples. +### Generated CLI + +The `cli` generator emits `.cli.ts`, the CLI module next to the client (`client.cli.ts` for `client.ts`). +This file is a zero-dependency command-line interface for the generated client, ready to use as a bin. +Path parameters are positional. +Query parameters become typed `--kebab-name` flags. +Enum flags list their choices in `--help`, and array parameters repeat the flag. +Supply a JSON request body with `--json ''`, `--json @file.json`, or `--json @-` (stdin). +The CLI validates each request before it sends it. +When you select `cli`, the command also selects the generators it needs (`typescript` and `zod`), so you do not have to list them. +Because of this, the CLI validation uses [zod](https://zod.dev/) at runtime. +Install zod next to the generated CLI (`npm i zod`). + +```sh +redocly generate-client openapi.yaml --output src/client.ts --generator typescript --generator cli +npx tsx src/client.cli.ts listOrders --status open --limit 10 +npx tsx src/client.cli.ts createOrder --json @order.json +npx tsx src/client.cli.ts listOrders --page-all # one JSON page per line +npx tsx src/client.cli.ts schema createOrder # the operation's full contract +``` + +**The operationId alone is the command**: ` listOrders`. +You do not have to know which tag the operation carries. +Run ` listOrders --help` to show the flags of one command. + +A tag adds a group, and the group organizes `--help`. +This matters for an API with hundreds of operations: ` --help` lists the groups, and ` orders --help` lists the commands of one group. +A group also addresses a command (` orders listOrders`), which is what you would type after browsing that group, but it is never required. +Two commands cannot share a name: when a description declares the same `operationId` twice, the generator reports it and emits the second as `_2`. +The first word is a group when it matches a group slug, and a command name in every other case. +Because of this, an operation that carries a tag and is also named after a tag (`operationId: orders` in an API that has an `orders` tag) is available as ` orders`, and ` orders` shows the `orders` group. +An operation with no tag keeps the bare form, because a group cannot address it, and the group of that name then has no help page. +The generator reports both cases when it writes the CLI, so you can rename the operation or the tag. +An operation with no `operationId` still gets a command. +The generator derives the name from the method and the path (`GET /pets` becomes `getPets`, and `GET /pets/{id}` becomes `getPetsId`), so a description without operationIds has a complete CLI. + +Group names and command names use different cases, and this is deliberate. +A group name comes from an OpenAPI tag, which is prose. +You cannot type `Coffee Orders` without quotes, so the CLI converts the tag to a slug: `coffee-orders`. +A command name is the operationId, which is already an identifier. +Because of this, the CLI uses it unchanged: `listOrders`, not `list-orders`. +As a result, the operation keeps one name in all generated output: the CLI command, the TypeScript function, and the Python method. +You can search for `listOrders` in your API description, in your SDK, and in your shell history. +The top-level help shows every global flag under `Global flags:`: `--server-url`, `--format json|ndjson`, `--dry-run`, `--page-all`, `--output`, `--token`, and `--json`. +The same section shows the environment variables that the CLI reads. + +The CLI reads credentials from environment variables. +The prefix is the output file name in constant case: `MY_API_*` for `my-api.ts`. +The prefix is fixed when the file is generated, so the variables stay the same whatever you install the command as. +To change them, rename the output file. +For bearer auth, use `_TOKEN` (or `--token`). +For basic auth, use `_USERNAME` and `_PASSWORD`. +For apiKey auth, use `_API_KEY_`. +The help lists only the schemes that the description declares. +An API with no bearer scheme shows no `--token` flag. +If you pass `--token` to such an API, the CLI reports a usage error (exit 4) and names the schemes that the API accepts. +The CLI does not drop the credential silently. +`--server-url` overrides the built-in server URL. +`--dry-run` prints the prepared request with the credentials redacted and does not send it. +Blob responses require `--output `. +SSE operations stream events as one JSON object per line. + +The exit codes are a documented contract. +Errors print one JSON object to stderr, so stdout stays clean for pipes: + +| Code | Meaning | +| ---- | --------------------------------------------------- | +| 0 | success | +| 1 | API error (status other than 401/403) | +| 2 | auth error (401/403) | +| 3 | validation error (zod co-selected) | +| 4 | usage error (unknown command or flag, bad `--json`) | + +`schema ` prints the complete contract of one operation as JSON. +The output includes the method and path, and the path and query parameters with their types and descriptions. +It also shows if the operation accepts a JSON body, the request and response schemas, and the flags that change call behavior (`paginated`, `sse`, `blob`). +This is the machine-readable surface of the CLI. +A script, a test harness, or an agent can discover the tool with `--help`. +It can then read one `schema` call for each command, and it does not have to parse help text written for humans. + +#### Compose and extend the CLI + +The generated module is a library and also a binary. +It exports `COMMANDS`, `wiring`, and `run`, and it executes itself only when it is the process entry. +This makes two things possible without changes to the generated files. + +**One binary for several APIs.** +Set a top-level `client.cliOutput`. +Then `redocly generate-client` (no api argument) emits a composed entry for every api that emits a cli module. +Each api's alias from `apis:` becomes its command namespace (`shop` and `kitchen` below). +The CLI reads each api's credentials under `__*`, where `` is the entry file name in constant case: + +```yaml +client: + cliOutput: ./src/cafe.ts + generators: [typescript, cli] +apis: + shop: { root: ./shop/openapi.yaml, clientOutput: ./src/shop.ts } + kitchen: { root: ./kitchen/openapi.yaml, clientOutput: ./src/kitchen.ts } +``` + +```sh +npx tsx src/cafe.ts shop listOrders --limit 3 # CAFE_SHOP_TOKEN +npx tsx src/cafe.ts kitchen createOrder --json @o.json # CAFE_KITCHEN_TOKEN +``` + +Two different things can stand in the word after the command, so compare the two setups. +For one API, an operationId is the whole command (`cafe listOrders`), and a tag slug goes in front of it only to resolve an ambiguous name (`cafe orders listOrders`). +For a composed binary, that first word is the api alias, because an operationId is unique only inside one description: `cafe shop listOrders`. +A tag group of that api nests inside its alias, again only when it is needed: `cafe shop orders listOrders`. + +An operationId is unique only inside one description. +Because of this, each command carries its api's alias as a namespace. +If two descriptions declare the same operationId, the result is two different commands. +Each api keeps its own server URL, schemes, and credentials. + +The CLI has no name of its own to configure. +It reads the name it was invoked as from the process, so `--help` always shows the command you typed. +When the process starts from the file itself, such as `node dist/cafe.cli.js` or a Windows `bin` shim, the help drops the script extension and shows `cafe`. +To type `cafe` instead of `npx tsx src/cafe.ts`, compile the entry and point the `bin` field of your `package.json` at the compiled file. +The end of this section shows this step. + +**Commands the description doesn't have.** +A custom command is the same data shape plus a `handler`. +Because of this, it inherits the help, the parsing, `schema`, and the exit codes. +Use this to add behavior that is not in a description, for example a `login` or a doctor command. +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 + +const login: CustomCommand = { + name: 'login', + summary: 'Fetch and store a token.', + handler: async ({ wiring }) => { + const token = await deviceFlow(); // yours: any flow the API offers + saveCredentials({ CAFE_SHOP_TOKEN: token }); // yours: file, keychain, anything + wiring.stdout('Logged in.'); + return 0; + }, +}; + +process.exit(await runCli([{ commands: [login] }, ...SOURCES], process.argv.slice(2))); +``` + +The CLI reads credentials from `wiring.env`. +The generated entry sets this field to `process.env`, and the composed entry keeps that value. +If your wrapper keeps a token in a file, write the token to `process.env` before the wrapper runs a command. +Use `Object.assign(process.env, stored)`. +The CLI then reads the token in the same way as a variable from the shell. +If the wrapper must not change the global environment, give the source its own env: +`{ ...source, wiring: { ...source.wiring, env: { ...process.env, ...stored } } }`. +The generator itself supplies no credential store and no login command. +The auth flow of each API is different, so you supply these parts. +This section shows the procedure. + +#### Ship it as a real command + +The command name is yours, and generation never sets it. +The generated file is a module until you point a `bin` field at it, and these three steps are what make `cafe` a command on your machine. + +First, the CLI uses top-level `await`, so the nearest `package.json` must set `"type": "module"`. +Without this setting, `tsx` reports `Top-level await is currently not supported with the "cjs" output format`, and that message does not point to the fix. + +Second, compile the entry with `tsc` and declare the compiled file as the bin. +For one API the entry is the CLI module, `.cli.ts`, so `src/cafe.ts` compiles to `dist/cafe.cli.js`. +For a composed binary the entry is `cliOutput` itself, so `./src/cafe.ts` compiles to `dist/cafe.js`. +The client module is not an entry, and a `bin` field that points at it gives you a command that does nothing: + +```json +{ + "type": "module", + "bin": { "cafe": "./dist/cafe.cli.js" }, + "scripts": { "build": "tsc" } +} +``` + +Third, install the package, or link it while you develop: + +```sh +npm run build && npm link +cafe listOrders --limit 3 # CAFE_TOKEN from the environment +``` + +The help output follows the name you install, because the CLI reads it from the process. +The credential variables do not: they come from the output file name, so a renamed command never invalidates the variables your users already set. +For a one-off run, `npx tsx src/cafe.cli.ts listOrders --limit 3` uses the same entry with no build step. +Only the `cli` generator emits a command: the `python`, `go`, and `php` SDKs are libraries. + +### Language SDKs + +The `python`, `go`, and `php` generators each emit a full SDK for that language. +The SDK is one self-contained file. +It has no dependencies other than the HTTP support of the language: `httpx` for Python, the standard library for Go, and the curl extension for PHP. + +One file is the intended deliverable, not a limitation. +Users can download the file from a docs page, commit it, and read it from start to end. +There is no package to publish and no import graph to connect. +A description the size of a large public API produces a file of a few megabytes. +Each of these languages loads a file of that size without problems. +If you want a different layout, [eject the generator](../commands/eject-generator.md). +The `run` function returns the list of files, so you can split the output with a change to your own copy. + +**They are the TypeScript client in another language.** +Every capability is the same: typed models with `allOf` flattened, enums, discriminated unions decoded by their discriminator, and one method per operation. +The SDKs also include [auth](#authentication), retries with `Retry-After` and jittered backoff, timeouts, idempotency keys, middleware, and pagination iterators. +They also include SSE streaming, multipart bodies, binary downloads, typed response-header envelopes, and server-URL helpers for templated servers. +Configuration is the same too: [`serverUrl`](../commands/generate-client.md), [`dateType`](../commands/generate-client.md), [`pagination`](../configuration/reference/client.md#pagination-object), and [`codeSamples`](../configuration/reference/client.md) all apply. +Each language names its output in its own way: the Python module comes from the output file name, the PHP namespace comes from the API title, and Go uses `package client` or [`goPackage`](../configuration/reference/client.md). +If you set an option that a language cannot apply, the generator prints a warning with the option name and the reason. +The option never disappears silently. + +```python +from openapi_client import Client + +client = Client(auth={"bearer": "TOKEN"}) +for order in client.list_orders_items(limit=50): + print(order) +``` + +The Python models are dataclasses, so `httpx` stays the only requirement. +If your project expects [pydantic](https://docs.pydantic.dev/) models, ask for them: + +```yaml +client: + generators: [python] + options: + python: + models: pydantic # default: dataclass +``` + +Every class then extends `BaseModel`, and a wire name that is not a legal Python field name becomes a field alias. +The call sites do not change: the same class names, the same field names, the same client. +Pydantic then validates each response as the SDK decodes it, so a response that does not match the description raises `ValidationError` instead of passing through. +This mode needs `pydantic` next to `httpx`, and the header of the generated file says so. +A discriminated union keeps its discriminator in both modes, and each member declares its own value as a `Literal`. +For this to work, every member schema must declare the discriminator property. +When a member omits it, pydantic matches the members of a nested union by shape, which can select the wrong one. + +```php +require 'client.php'; + +use CafeOrders\{Client, Config}; + +$client = new Client(new Config(auth: ['bearer' => 'TOKEN'])); +foreach ($client->listOrdersItems(limit: 50) as $order) { + echo $order->id, PHP_EOL; +} +``` + +```go +api := client.New(client.Config{Auth: client.Auth{Bearer: func() string { return "TOKEN" }}}) + +for order, err := range api.ListOrdersItems(ctx, nil) { + if err != nil { + break + } + fmt.Println(order.Id) +} +``` + +#### Auth, middleware, and reserved names by language + +Every language gives credentials to a client instance, and the constructor is that one way. +`createClient(OPERATIONS, { auth })` in TypeScript is the same thing as the constructors below. +TypeScript adds `configure({ auth })` for one reason: it also exports a module-level client, whose methods the module exports by name, and `configure` is how you set up that instance. +The Python, PHP, and Go SDKs export no module-level client, so they need no equivalent. + +Auth accepts a static credential, or a provider function that the client resolves for each request: + +```python +client = Client(auth={"bearer": "TOKEN"}) +client = Client(auth={"bearer": lambda: fresh_token()}) +client = Client(auth={"apiKey": {"SecretApiKey": "KEY"}}) # "api_key" also accepted +``` + +```php +$client = new Client(new Config(auth: ['bearer' => 'TOKEN'])); +$client = new Client(new Config(auth: ['bearer' => fn () => freshToken()])); +$client = new Client(new Config(auth: ['apiKey' => ['SecretApiKey' => 'KEY']])); +``` + +```go +// Go has no union types, so a credential is always a function — even a static one. +api := client.New(client.Config{Auth: client.Auth{ + Bearer: func() string { return "TOKEN" }, + APIKey: map[string]func() string{"SecretApiKey": func() string { return "KEY" }}, +}}) +``` + +Middleware follows the natural shape of each language. +It is **not** PSR-15/PSR-18 or an HTTPX event hook. +It is this contract: + +```php +// PHP: an onion. Each callable receives the request array and the next link. +// Request keys: operationId, method, url, headers, query, and optionally body, +// contentType, idempotencyKey. The response array carries status, headers, body, +// url, timedOut. +$log = function (array $request, callable $next) use ($logger): array { + $logger->info('request', ['op' => $request['operationId'], 'url' => $request['url']]); + $response = $next($request); + $logger->info('response', ['status' => $response['status']]); + return $response; +}; +$client = new Client(new Config(middleware: [$log])); +``` + +```python +# Python: hooks. on_request sees the request context; on_response may return a +# replacement response. +import logging + +def log_request(context): + logging.info("%s %s", context["method"], context["url"]) + +client = Client(middleware=[{"on_request": log_request}]) +``` + +```go +// Go: hooks on the real *http.Request / *http.Response. +api := client.New(client.Config{Middleware: []client.Middleware{{ + OnRequest: func(r *http.Request) { log.Println(r.Method, r.URL) }, + OnResponse: func(r *http.Response) { log.Println(r.Status) }, +}}}) +``` + +A property or parameter whose name is a reserved word gets a trailing underscore. +The wire name does not change. +For example, `tag.type_` in Python, `$tag->type_` in PHP, and `tag.Type_` in Go all serialize as `type`. +The same applies to method arguments: `list_tags(type_=...)`, `ListTagsParams{Type_: ...}`. + +OpenAPI lets one operation use the same parameter name in two locations, such as `id` in the path and `id` in the query. +The SDKs whose methods take one argument per parameter cannot declare that name twice, so the later parameter gets a suffix: `id_2` in Python, `$id2` in PHP, `id2` in Go. +A parameter named after an argument the method declares itself, such as `body` or `headers`, moves aside the same way. +The wire names never change, so both values reach the API as written, and the generator reports each rename. +To choose the names yourself, rename the parameter in the description. +The TypeScript client needs no rename, because each layer of its input is a separate object. + +The generator resolves type and method **names** once, in the shared model. +It checks them against a reserved set that is the union across the supported languages. +Because of this, a schema keeps the same name in every SDK that you generate from the description. +For example, `Error` becomes `Error_2` in the Python SDK too, although Python accepts `Error`. +As a result, the TypeScript, Python, PHP, and Go clients of an API share one vocabulary. +The generator reports each rename with its cause. +A publisher who wants a different name can rename the schema or the operation in the description. + +### Reference documentation + +`client.docs: true`, or the `--docs` flag, also writes the reference documentation for what the run generates. +Each generator documents itself, and it writes one Markdown page next to its own output: + +| Generator | Page | Contents | +| --------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------ | +| `cli` | `.cli.md` | The usage line, the global flags, the credential variables, the exit codes, and every command. | +| `typescript` | `.typescript.md` | The security schemes, and every operation with its parameters, body, response type, and a call sample. | +| `python`, `go`, `php` | `..md` | The same page for that SDK, with its own call samples. | + +```sh +redocly generate-client openapi.yaml --output src/client.ts --generator cli --generator python --docs +``` + +One switch covers every language, so a newly documented generator needs no new flag. +A generator that documents nothing, such as `zod`, writes no page. +Each page takes its call samples from the generator's own `sample` hook, so a page shows the syntax of the artifact beside it. +The CLI page renders from the same command table that the CLI dispatches on. +Because of this, a page cannot describe something other than what the run produced. + +Set `client.docsFrontmatter: true` to put YAML front matter with the title above each page, for docs sites that expect it. +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 runtime is embedded in the generated file, so the client is self-contained. -With [`--runtime package`](../commands/generate-client.md#choose-a-runtime) the generated file instead imports the runtime from `@redocly/client-generator` — your application code is **identical in both modes** (same exports, same call shapes); only where the engine lives changes. -Choose `package` when you want engine fixes and improvements via `npm update @redocly/client-generator`, with no regeneration. +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`: @@ -43,14 +424,18 @@ client: runtime: package # default: inline (self-contained) ``` -An incompatible generated-file/runtime pair fails your `tsc` build (the descriptor `satisfies` check) rather than misbehaving at runtime. +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 (type stripping), so you can execute a script that uses the generated client with plain `node` — no `tsx`, no build step. -Node resolves import specifiers literally — there is no `.js` → `.ts` remap — so generate with [`--import-ext ts`](../commands/generate-client.md#options) to get real on-disk `.ts` specifiers, and import the client with a `.ts` extension in your own code: +Node 22.7+ runs TypeScript natively with type stripping. +Because of this, you can run a script that uses the generated client with plain `node`, without `tsx` and without a build step. +Node resolves import specifiers literally, with no `.js` to `.ts` remap. +Because of this, generate with [`--import-ext ts`](../commands/generate-client.md#options) to get real on-disk `.ts` specifiers. +Import the client with a `.ts` extension in your own code: ```bash redocly generate-client openapi.yaml -o src/api/client.ts --import-ext ts @@ -60,43 +445,69 @@ redocly generate-client openapi.yaml -o src/api/client.ts --import-ext ts // src/main.ts import { listMenuItems } from './api/client.ts'; -const menu = await listMenuItems({ limit: 3 }); +const menu = await listMenuItems({ query: { limit: 3 } }); ``` ```bash node src/main.ts ``` -Keep the default `js` when the client goes through `tsc` or a bundler — plain `tsc` rejects `.ts` specifiers unless the project enables `allowImportingTsExtensions`. +Keep the default `js` when the client goes through `tsc` or a bundler. +Plain `tsc` rejects `.ts` specifiers unless the project enables `allowImportingTsExtensions`. Loaders such as `tsx` remap `.js` to `.ts` themselves, so they work with the default. See the [`node-native` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/node-native). +**Every generated TypeScript file is erasable TypeScript**, so type stripping alone is enough. +The client, the zod module, and the generated CLI all run under plain `node` with no build step. +No emitted code needs a transform to become JavaScript. +The output contains no `enum`, no `namespace`, and no constructor parameter properties (`constructor(readonly id: string)`). +Strip-only mode rejects these constructs, because it would have to generate assignments. + ## Authentication -Credentials are **per instance**: they live in the client's config (`ClientConfig.auth`), and each operation automatically sends the credentials its `security` requires. -A setter is generated for each `securityScheme` the runtime can apply: +Credentials are **per instance**. +They live in the client config (`ClientConfig.auth`). +Each operation automatically sends the credentials that its `security` requires. +A description that declares no `securitySchemes` produces a client with no auth code. +Set credentials in one of two places, and both configure the same instance: -| Scheme | Setter | Applied as | -| ------------------------------ | ----------------------------------------- | ---------------------------------------- | -| HTTP `bearer` / OAuth2 | `setBearer(token)` | `Authorization: Bearer ` | -| HTTP `basic` | `setBasicAuth(user, pass)` | `Authorization: Basic ` | -| `apiKey` (header/query/cookie) | `setApiKey(key)` / `setApiKey(key)` | the named header, query param, or cookie | +```ts +import { client, configure } from './client.ts'; + +// Up front, with the rest of the configuration. +configure({ auth: { bearer: process.env.API_TOKEN } }); + +// Or one scheme at a time, by kind. +client.auth.bearer(process.env.API_TOKEN); +client.auth.basic({ username: 'svc', password: 's3cr3t' }); +client.auth.apiKey('SecretApiKey', process.env.API_KEY); // addressed by scheme key +``` -`setApiKey` is unsuffixed for a single apiKey scheme; otherwise each gets `setApiKey`. -`mutualTLS` is not injectable. -Cookie apiKey credentials travel in the `Cookie` request header, which browsers refuse to set — cookie auth works only in server-side clients (the generator warns when a spec declares one). -Bearer and apiKey credentials accept a **`TokenProvider`** — a string or a (possibly async) function called per request, useful for refresh flows: +| Scheme | How you set it | Applied as | +| ------------------------------ | ------------------------------------ | ---------------------------------------- | +| HTTP `bearer` / OAuth2 | `auth.bearer(token)` | `Authorization: Bearer ` | +| HTTP `basic` | `auth.basic({ username, password })` | `Authorization: Basic ` | +| `apiKey` (header/query/cookie) | `auth.apiKey('', value)` | the named header, query param, or cookie | + +Each operation sends only the credentials its own `security` requires, so setting several is normal. +An apiKey scheme is addressed by the key the description gives it, so an API with several apiKey schemes needs no extra names. +The runtime cannot inject `mutualTLS`. +Cookie apiKey credentials travel in the `Cookie` request header, and browsers refuse to set this header. +Because of this, cookie auth works only in server-side clients. +The generator warns when a spec declares a cookie scheme. +Bearer and apiKey credentials accept a **`TokenProvider`**: a string, or a function (possibly async) that the client calls for each request. +This is useful for refresh flows: ```ts -import { setBearer } from './client.ts'; +import { client } from './client.ts'; -setBearer(async () => await getFreshAccessToken()); +client.auth.bearer(async () => await getFreshAccessToken()); ``` -Each setter is shorthand for the exported `client` instance's `auth` member (`export const setBearer = client.auth.bearer;`), so it configures that instance. -Equivalently, pass credentials up front with `configure({ auth: { … } })` or set them via `client.auth.bearer(…)` / `client.auth.basic(…)` / `client.auth.apiKey(scheme, …)`. +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 over the same generated descriptors — the generated module exports `createClient`, the `OPERATIONS` descriptors, and the `Ops` type in both runtimes: +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: ```ts import { createClient } from '@redocly/client-generator'; @@ -111,36 +522,77 @@ const publicApi = createClient(OPERATIONS, { serverUrl: 'https://api.exampl ## Argument style -By default (`--args-style flat`) each operation takes positional arguments — path params in URL order, then `params` (query), `body`, `headers`, and `cookies` — with the per-call `init` last. -Cookie parameters are serialized into the `Cookie` request header, which browsers refuse to set — like cookie apiKey auth, they work only in server-side clients. -With `--args-style grouped`, every input is bundled into one `vars` object typed as the operation's `Variables`: +Every operation takes one input object and an optional per-call `init`. +By default (`--args-style grouped`), the input groups its values by transport layer: `path`, `query`, `headers`, `cookies`, and `body`. +Each key is a sibling of the others, and the type of the whole object is the operation's `Variables`: ```ts -// flat (default) -await updateOrder('ord_01khr…', { ...orderBody }); +await updateOrder({ + path: { orderId: 'ord_01khr…' }, + query: { dryRun: true }, + headers: { 'X-Request-Id': requestId }, + body: { ...orderBody }, +}); +``` -// grouped — order-independent, a good fit for React Query / SWR mutationFns -await updateOrder({ orderId: 'ord_01khr…', body: { ...orderBody } }); +The layer names come from the description itself, so a call reads like the operation it calls, adding a parameter never changes how existing calls are written, and no name can collide with another. + +With `--args-style flat`, the same values are merged into one level, which is shorter for an operation with a single kind of input: + +```ts +await updateOrder({ orderId: 'ord_01khr…', dryRun: true, ...orderBody }); ``` -An unknown top-level key in the grouped object (for example a leftover flat-style `{ limit: 10 }` instead of `{ params: { limit: 10 } }`) fails the call with a `TypeError` naming the key. -TypeScript catches this at compile time; the runtime check covers transpilers that skip type-checking, so a mis-shaped call never silently drops data. +Flat merges the properties of a required object body. +A body that is optional, or that is not an object (an array, a scalar, or a binary payload), keeps its own `body` key. +When one name would arrive from two layers, that operation keeps the grouped shape, because a merged call could not say which value is which. + +The client serializes cookie parameters into the `Cookie` request header, and browsers refuse to set this header. +Because of this, cookie parameters, like cookie apiKey auth, work only in server-side clients. + +An unknown top-level key fails the call with a `TypeError` that names the key and lists the layers. +TypeScript catches this at compile time; the runtime check covers transpilers that skip type checks. +Because of this, a call with the wrong shape never drops data silently. + +## Read-only properties + +The server manages a property marked `readOnly: true`. +Because of this, the generated request body type leaves the property out. +A body that references a named schema becomes `Omit`. +An inline object drops those properties. +Response types keep them. +The zod schemas and the mock factories read the same flag, so the type, the runtime validation, and the fixtures agree. + +The position of `readOnly` matters, and it follows the specification version: + +- **OpenAPI 3.1** uses JSON Schema 2020-12, where `$ref` is an ordinary keyword. + Keywords next to a `$ref` take effect. + Because of this, `{ $ref: './Entitlements.yaml', readOnly: true }` marks the property read-only. +- **OpenAPI 3.0 and 2.0** are older than that model. + A `$ref` replaces the whole schema object, so a sibling `readOnly` has no meaning, and the generator ignores it. + Generation warns when it finds a sibling `readOnly` and names the property. + The intent is usually clear, and silence would keep the property in every request body. + The [`spec-ref-siblings`](../rules/oas/spec-ref-siblings.md) rule flags the same thing when you lint. + To mark a referenced property read-only in 3.0, inline the schema or wrap the `$ref` in an `allOf`. ## Error handling -By default (`--error-mode throw`) an operation throws `ApiError` on any non-2xx response and returns the success body directly. -With `--error-mode result` it never throws for HTTP errors, returning a discriminated `Result` whose `error` is typed from the description's 4xx/5xx bodies: +By default (`--error-mode throw`), an operation throws `ApiError` on a non-2xx response. +It returns the success body directly. +With `--error-mode result`, the operation never throws for HTTP errors. +It returns a discriminated `Result`. +The `error` type comes from the 4xx/5xx bodies in the description: ```ts // throw (default) try { - const order = await getOrderById('ord_123'); + const order = await getOrderById({ path: { orderId: 'ord_123' } }); } catch (err) { if (err instanceof ApiError) console.error(err.status, err.body); } // result -const { data, error, response } = await getOrderById('ord_123'); +const { data, error, response } = await getOrderById({ path: { orderId: 'ord_123' } }); if (error) console.error(response.status, error.title); else console.log(data.id); ``` @@ -150,8 +602,10 @@ The choice is fixed at generate time. ## Middleware -Beyond the single `onRequest`/`onResponse`/`onError` hooks on `ClientConfig`, the client takes **composable middleware** for cross-cutting concerns (auth refresh, logging, tracing, request IDs). -Register with `use()` (shorthand for `client.use()`); it accepts several at once: +The client has single `onRequest`/`onResponse`/`onError` hooks on `ClientConfig`. +It also takes **composable middleware** for concerns that apply to many calls: auth refresh, logs, traces, and request IDs. +Register middleware with `use()`, a shorthand for `client.use()`. +It accepts several middleware at once: ```ts import { use } from './client.ts'; @@ -166,54 +620,117 @@ use({ }); ``` -`onRequest` runs in registration order; `onResponse` runs in reverse order. -`onRequest` may mutate `ctx` (`url`, `method`, `headers`, and `body` — body edits are serialized and sent); `onResponse` may return a replacement `Response`. -`onError` (throw mode only) is threaded through each middleware. -`ctx.operation`'s fields are typed as literal unions from the description (`OperationId`/`OperationPath`/`OperationTag`), so `ctx.operation.id === '…'` and `ctx.operation.tags.includes('…')` autocomplete, and a misspelled operation id fails compilation instead of silently never matching. -A header for a single call instead goes in that operation's trailing `init` argument. -Per-request headers merge lowest to highest — the caller always wins: +`onRequest` hooks run in registration order. +`onResponse` hooks run in reverse order. +`onRequest` can change `ctx`: `url`, `method`, `headers`, and `body`. +The client serializes and sends body edits. +`onResponse` can return a replacement `Response`. +The client threads `onError` (throw mode only) through each middleware. +The fields of `ctx.operation` are typed as literal unions from the description (`OperationId`/`OperationPath`/`OperationTag`). +Because of this, `ctx.operation.id === '…'` and `ctx.operation.tags.includes('…')` autocomplete. +An operation id with a spelling error fails compilation, and it does not silently miss all matches. +To set a header for a single call, use the trailing `init` argument of that operation. +Per-request headers merge from the lowest to the highest priority, and the caller always wins: 1. Injected auth credentials. 2. Typed header parameters. 3. The caller's `init.headers`. -Outside browsers, the client also identifies itself to the API with an `X-Redocly-Client` header (useful for the API owner's telemetry). -Override it with `configure({ clientHeader: 'my-service/2.0' })`, or disable it with `clientHeader: false`. -Browsers never send it — a custom header would force a CORS preflight. +Outside browsers, the client also identifies itself to the API with an `X-Redocly-Client` header. +The API owner can use this header for telemetry. +Override the header with `configure({ clientHeader: 'my-service/2.0' })`. +Disable it with `clientHeader: false`. +Browsers never send it, because a custom header would force a CORS preflight. -`use()` appends to the middleware chain, composing with any already-registered or publisher pre-configured middleware. -`configure({ middleware: [...] })` replaces the whole chain — use it to reset, but prefer `use()` to add to existing (including [publisher pre-configured](./customize-client-generation.md#publisher-defaults)) middleware. +`use()` appends to the middleware chain. +It composes with middleware that is already registered or that the publisher pre-configured. +`configure({ middleware: [...] })` replaces the whole chain. +Use it to reset the chain. +But prefer `use()` to add to existing middleware, including [publisher pre-configured](./customize-client-generation.md#publisher-defaults) middleware. See the [`configure-and-middleware` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/configure-and-middleware) for a runnable version. +## The HTTP layer + +The client sends requests with `fetch`, and that is the only transport it needs. +Auth, retries, timeouts, and middleware are part of the client, so you do not add a request library to get them. + +If your application already has a configured HTTP layer, pass it to the client instead of replacing what you have. +`ClientConfig.fetch` accepts anything with the `fetch` signature, so an existing instance of your request library goes in through one adapter function: + +```ts +import axios from 'axios'; +import { configure } from './client.ts'; + +// One adapter, and every generated call goes through your instance: +// its interceptors, its base configuration, its telemetry. +configure({ + fetch: async (input, init) => { + const response = await axios.request({ + url: typeof input === 'string' ? input : input.toString(), + method: init?.method ?? 'GET', + headers: init?.headers as Record, + data: init?.body, + responseType: 'text', + validateStatus: () => true, + }); + return new Response(response.data, { + status: response.status, + headers: response.headers as HeadersInit, + }); + }, +}); +``` + +The same seam takes a test double, a proxy-aware fetch, or a `fetch` that adds tracing headers. +Prefer [middleware](#middleware) for behavior that belongs to your API, and keep `fetch` for the transport itself. + ## Retries -Retry is **opt-in**, configured through `ClientConfig` with an optional per-call override: +Retry is **opt-in**. +Configure it through `ClientConfig`, with an optional per-call override: ```ts configure({ retry: { retries: 3 } }); // the module's client instance const other = createClient(OPERATIONS, { retry: { retries: 3 } }); // another instance -await getOrderById('ord_123', {}, { retry: { retries: 5 } }); // per call +await getOrderById({ path: { orderId: 'ord_123' } }, { retry: { retries: 5 } }); // per call ``` -By default only **idempotent** methods (`GET`, `HEAD`, `PUT`, `DELETE`, `OPTIONS`) are retried, on a network error or a transient status (`408`, `429`, `500`, `502`, `503`, `504`). -`POST`/`PATCH` are not, since re-sending can duplicate side effects — opt in with a custom `retryOn` when safe. +By default, the client retries only **idempotent** methods (`GET`, `HEAD`, `PUT`, `DELETE`, `OPTIONS`). +It retries them on a network error or a transient status (`408`, `429`, `500`, `502`, `503`, `504`). +The client does not retry `POST`/`PATCH`, because a repeated send can duplicate side effects. +Opt in with a custom `retryOn` when a retry is safe. -A custom `retryOn` **replaces** the default policy entirely — a predicate like `({ response }) => (response?.status ?? 0) >= 500` silently stops retrying network errors and timeouts, which have no `response`. +A custom `retryOn` **replaces** the whole default policy. +A predicate like `({ response }) => (response?.status ?? 0) >= 500` silently stops retries for network errors and timeouts, because these have no `response`. Compose with the exported default instead: `retryOn: (ctx) => defaultRetryOn(ctx) || myRule(ctx)`. -For APIs that support [idempotency keys](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/), set `idempotencyKey: true` (or a key factory) on the instance: every `POST`/`PATCH` gets an `Idempotency-Key` header — one stable key per logical call, re-sent unchanged on every retry attempt — and the default retry policy then treats those requests as safe to retry. -Per call, pass a literal key (`{ idempotencyKey: 'order-42-submit' }`) or `false` to skip; a caller-set `Idempotency-Key` header always wins. -Backoff is exponential with full jitter (`retryStrategy: 'fixed'` for a constant delay); a `Retry-After` header takes precedence; an aborted `AbortSignal` stops retries immediately. - -A `timeout` (milliseconds) aborts an attempt that takes too long — including reading the body — and composes with your own `AbortSignal`. -Each retry attempt gets a fresh budget; a timed-out attempt retries under the same policy as a network error. -When retries are exhausted, the failure surfaces as a `TimeoutError` (exported next to `ApiError`) carrying `operationId`, the effective `timeout`, and the `attempt` number — everything a log line needs. -Set it on the instance (`configure({ timeout: 10_000 })`) or per call (`{ timeout: 500 }`, where `0` disables the instance default). -SSE streams are long-lived by design and never inherit the instance timeout. - -A retry **resends the same request** — the `onRequest` chain, `config.headers()`, and body serialization run once and are reused across attempts. -To refresh a token, signature, or timestamp per attempt, do it in `onResponse`/`onError` or a custom `retryOn` rather than expecting `onRequest` to re-run. +For APIs that support [idempotency keys](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/), set `idempotencyKey: true` (or a key factory) on the instance. +Then every `POST`/`PATCH` gets an `Idempotency-Key` header. +The key is one stable value per logical call, and each retry attempt sends the same value. +The default retry policy then treats those requests as safe to retry. +Per call, pass a literal key (`{ idempotencyKey: 'order-42-submit' }`), or pass `false` to skip the header. +An `Idempotency-Key` header set by the caller always wins. +Backoff is exponential with full jitter. +Set `retryStrategy: 'fixed'` for a constant delay. +A `Retry-After` header takes precedence. +An aborted `AbortSignal` stops retries immediately. + +A `timeout` (milliseconds) aborts an attempt that takes too long, including the body read. +The timeout composes with your own `AbortSignal`. +Each retry attempt gets a fresh time budget. +An attempt that times out retries under the same policy as a network error. +When no retries remain, the failure surfaces as a `TimeoutError`, exported next to `ApiError`. +The error carries the `operationId`, the effective `timeout`, and the `attempt` number. +This is everything a log line needs. +Set the timeout on the instance (`configure({ timeout: 10_000 })`) or per call (`{ timeout: 500 }`). +A per-call value of `0` disables the instance default. +SSE streams stay open by design and never inherit the instance timeout. + +A retry **resends the same request**. +The `onRequest` chain, `config.headers()`, and body serialization run once, and all attempts reuse the result. +To refresh a token, a signature, or a timestamp for each attempt, do it in `onResponse`/`onError` or in a custom `retryOn`. +Do not expect `onRequest` to run again. | `RetryConfig` field | Type | Default | | ------------------- | ---------------------------------------------------- | -------------------------------------------------- | @@ -223,19 +740,23 @@ To refresh a token, signature, or timestamp per attempt, do it in `onResponse`/` | `jitter` | `boolean` | `true` | | `retryOn` | `(ctx: RetryContext) => boolean \| Promise` | idempotent-only predicate | -A custom `retryOn` receives the failed attempt's `RetryContext` (`attempt`, `request`, and exactly one of `response` / `error`) and **fully replaces** the default. -To inspect a response body, read `ctx.response.clone()` — the body is a single-use stream: +A custom `retryOn` receives the `RetryContext` of the failed attempt: `attempt`, `request`, and exactly one of `response` / `error`. +It **fully replaces** the default. +To examine a response body, read `ctx.response.clone()`, because the body is a single-use stream: ```ts -await createOrder(body, { - retry: { - retries: 3, - retryOn: async (ctx) => { - if (ctx.error) return true; // transport error - return (ctx.response?.status ?? 0) >= 500; // server error +await createOrder( + { body }, + { + retry: { + retries: 3, + retryOn: async (ctx) => { + if (ctx.error) return true; // transport error + return (ctx.response?.status ?? 0) >= 500; // server error + }, }, - }, -}); + } +); ``` ## Query serialization @@ -250,13 +771,16 @@ The default (`form`, `explode: true`) repeats array values: | `spaceDelimited` | `false` | `key=a%20b` | | `pipeDelimited` | `false` | `key=a\|b` | -Delimiters are literal (values are still percent-encoded). -`allowReserved: true` leaves the RFC-3986 reserved set un-encoded. -Object-valued params serialize as `deepObject` brackets (`key[sub]=val`). +Delimiters are literal. +The client still percent-encodes the values. +`allowReserved: true` keeps the RFC-3986 reserved set un-encoded. +Parameters with object values serialize as `deepObject` brackets (`key[sub]=val`). ## Multipart uploads -A `multipart/form-data` body whose schema is an **object** is generated as a typed object; pass a plain object and the client serializes it to `FormData` (after the `onRequest` chain, so middleware can mutate it). +A `multipart/form-data` body whose schema is an **object** generates as a typed object. +Pass a plain object, and the client serializes it to `FormData`. +The serialization happens after the `onRequest` chain, so middleware can change the object. Binary fields (`format: binary`) are typed as `Blob`: ```ts @@ -264,13 +788,15 @@ Binary fields (`format: binary`) are typed as `Blob`: await upload({ file, orgId: 'org_1', tags: ['a', 'b'] }); ``` -`Blob`/strings pass through, arrays append one field per item, nested objects are JSON-encoded, `undefined`/`null` are skipped. -A multipart body whose schema isn't a concrete object keeps the raw `FormData` type. +`Blob` values and strings pass through unchanged. +Arrays append one field per item. +The client JSON-encodes nested objects and skips `undefined`/`null`. +A multipart body whose schema is not a concrete object keeps the raw `FormData` type. `format: byte` (base64) stays a `string`. ## Response decoding -The client reads each response by negotiating from its `Content-Type` (JSON, then `text/*`, then `Blob`). +The client selects a reader for each response from its `Content-Type`: JSON, then `text/*`, then `Blob`. Force a reader per call with `parseAs`: ```ts @@ -281,42 +807,56 @@ const res = await getMenuItemPhoto('prd_123', { parseAs: 'stream' }); It changes the runtime reader only, not the static return type. An operation whose success response declares no content is typed `void`. -However, if the server sends a JSON body anyway (a gap in the API description), the runtime still parses and returns it rather than silently dropping real data. -Reach it with a cast while the description catches up. +But if the server sends a JSON body anyway (a gap in the API description), the runtime still parses and returns the body. +It does not drop real data silently. +Access the body with a cast until the description declares it. ## Response headers (envelope) -By default throw mode returns only the parsed success body. -When you need response headers (pagination totals, rate limits, `Location`, and so on) without switching to `--error-mode result`, pass `{ envelope: true }` on that call: +By default, throw mode returns only the parsed success body. +Sometimes you need response headers, for example pagination totals, rate limits, or `Location`. +To get them without a switch to `--error-mode result`, pass `{ envelope: true }` on that call: ```ts -// Flat args (default): query/body slots, then per-call init. -const { data, headers, response } = await listCustomers({ limit: 1 }, { envelope: true }); +// The inputs come first, the per-call options second. +const { data, headers, response } = await listCustomers( + { query: { limit: 1 } }, + { envelope: true } +); headers.paginationTotal; // number — required Pagination-Total in the description headers.xFlag; // boolean | undefined — optional X-Flag response.headers.get('X-Undocumented'); // anything not declared in OpenAPI -// Grouped args / instance client: trailing init is always separate. -const envelope = await client.listCustomers({ params: { limit: 1 } }, { envelope: true }); +// The instance client is the same function under another name. +const envelope = await client.listCustomers({ query: { limit: 1 } }, { envelope: true }); ``` -- `headers` is a safe camelCase object of headers declared on the operation's success response. - String, number, and boolean schemas drive the TypeScript type and number/boolean coercion. - Complex header schemas remain strings because HTTP exposes header values as text. - Required response headers are required properties — the type trusts the API description, the same way response body types do. - Colliding normalized names get a deterministic numeric suffix. -- `response` is the raw `Response` — use it for undocumented headers. +- `headers` is a safe camelCase object of the headers declared on the operation's success response. + String, number, and boolean schemas drive the TypeScript type and the number/boolean coercion. + Complex header schemas stay strings, because HTTP exposes header values as text. + Required response headers are required properties. + The type trusts the API description, the same as the response body types do. + Normalized names that collide get a deterministic numeric suffix. +- `response` is the raw `Response`. + Use it for undocumented headers. - Non-2xx responses still throw `ApiError`. -- Default call sites stay body-only (non-breaking), including calls that pass other options (`headers`, `signal`, `parseAs`, a retry override). -- In `--error-mode result` the flag is ignored; that mode already returns `response`. -- The TanStack Query and SWR wrappers don't accept `envelope`. - It's excluded from their options and stripped from the forwarded call, so cached data is always the plain body. - Call the sdk function directly when you need headers. +- Default call sites continue to return only the body, so the flag is non-breaking. + This includes calls that pass other options (`headers`, `signal`, `parseAs`, a retry override). +- In `--error-mode result`, the client ignores the flag. + That mode already returns `response`. +- The TanStack Query and SWR wrappers do not accept `envelope`. + Their options exclude it, and the wrappers strip it from the forwarded call. + Because of this, cached data is always the plain body. + Call the client's operation function directly when you need headers. +- The Python, PHP, and Go SDKs expose the same information as separate variants: `_with_headers()`, `WithHeaders()`, and `WithHeaders`. + The generator emits these variants only for operations that declare success-response headers. + Those languages cannot change a return type with a flag. ## Runtime validation -The `zod` generator emits `operationSchemas` — request/response validators keyed by operationId — and the `zodValidation` middleware that wires them into the client: +The `zod` generator emits `operationSchemas`, a set of request and response validators keyed by operationId. +It also emits the `zodValidation` middleware that connects them to the client: ```ts import { use } from './api/client'; @@ -325,21 +865,36 @@ import { zodValidation } from './api/client.zod'; use(zodValidation()); // validate request bodies and JSON responses ``` -The two directions default differently, because they catch different parties' bugs: - -- An invalid **request** body throws `ZodValidationError` before any network call — it is the caller's own bug, caught at the cheapest possible moment. -- A successful JSON **response** that drifts from its schema **warns by default** (via `console.warn`, or a custom `onViolation` callback) and lets the call succeed — a server drifting from its description should not crash the consumer. Pass `response: 'throw'` for the strict behavior (it then throws even on result-mode clients), or `response: false` to skip. - -`ZodValidationError` carries `operationId`, `direction`, the raw zod `issues`, and flattened `violations` — each with the full nested path (union branches included) and a truncated preview of the offending value, so the failing field is identifiable without reproducing the payload. -Note that previews can surface payload data; point `onViolation` at a scrubbed logger when responses may carry secrets. - -For servers that reject undeclared properties, `stripRequestBodies: true` replaces the outgoing body with the parsed result, dropping any key the schema does not declare (a spread like `{ ...entity }` compiles past TypeScript's excess-property check but would otherwise reach the wire as-is). -Operations without a JSON body pass through untouched, and payloads are never mutated unless `stripRequestBodies` is set. -Pass `{ request: false }` to narrow the scope, or import a schema from `operationSchemas` for a one-off check. +The two directions have different defaults, because they catch bugs from different parties: + +- An invalid **request** body throws `ZodValidationError` before a network call. + This is the caller's own bug, caught at the least expensive moment. +- A successful JSON **response** that does not match its schema **warns by default** and lets the call succeed. + The warning goes to `console.warn` or to a custom `onViolation` callback. + A server that does not match its description must not crash the consumer. + Pass `response: 'throw'` for the strict behavior; it then throws even on result-mode clients. + Pass `response: false` to skip response validation. + +`ZodValidationError` carries the `operationId`, the `direction`, the raw zod `issues`, and the flattened `violations`. +Each violation has the full nested path (union branches included) and a truncated preview of the bad value. +Because of this, you can identify the failing field without a reproduction of the payload. +Note that previews can show payload data. +Point `onViolation` at a scrubbed logger when responses can carry secrets. + +Some servers reject properties that the schema does not declare. +For those servers, set `stripRequestBodies: true`. +It replaces the outgoing body with the parsed result and drops each key that the schema does not declare. +A spread like `{ ...entity }` compiles past TypeScript's excess-property check, but without this option it reaches the wire unchanged. +Operations without a JSON body pass through unchanged. +The middleware never changes a payload unless you set `stripRequestBodies`. +Pass `{ request: false }` to narrow the scope. +Or import a schema from `operationSchemas` for a single check. ## Operation metadata -The client exports an `OPERATIONS` map keyed by operationId — the same **operation descriptors** the runtime routes requests by, holding each operation's `method`, `path` template, `tags`, and wire shape: +The client exports an `OPERATIONS` map keyed by operationId. +These are the same **operation descriptors** that the runtime uses to route requests. +Each descriptor holds the operation's `method`, `path` template, `tags`, and wire shape: ```ts export const OPERATIONS = { @@ -348,26 +903,35 @@ export const OPERATIONS = { } as const satisfies Record; ``` -Because keys and values are plain string literals, they survive bundling/minification — making `OPERATIONS` the stable handle for cache keys, span names, or log labels (rather than `fn.name`, which a minifier can rename). -Every client method also carries its own identity as `client.getOrderById.operationId` — an explicit, minification-proof cache key for consumer wrappers (react-query keys and the like). +The keys and values are plain string literals, so they survive bundlers and minifiers. +Because of this, `OPERATIONS` is the stable handle for cache keys, span names, or log labels. +Do not use `fn.name`, because a minifier can rename it. +Every client method also carries its own identity as `client.getOrderById.operationId`. +This is an explicit cache key for consumer wrappers (react-query keys and the like), and a minifier cannot break it. The same `OperationId` / `OperationPath` / `OperationTag` unions type `ctx.operation` in middleware. ## Discriminated unions -A `oneOf` / `anyOf` with a usable discriminator gets an exported `is` type guard per member, taken from the description's `discriminator` or inferred when every member pins a shared property to a distinct string `const`: +A `oneOf` / `anyOf` with a usable discriminator gets an exported `is` type guard for each member. +The discriminator comes from the description's `discriminator`. +The generator can also infer it when every member sets a shared property to a distinct string `const`: ```ts export type MenuItem = Beverage | Dessert; export function isBeverage(value: MenuItem): value is Beverage { … } ``` -Guards are also emitted for unions nested inside another schema (array items, property values) as long as every member is a named schema. +The generator also emits guards for unions nested inside another schema (array items, property values), if every member is a named schema. A union without a usable discriminator gets no guard. ## Server-Sent Events -An operation whose `2xx` response declares `text/event-stream` is generated as a typed **async-generator function** (a client method plus the matching free function) — no flag required. -Each event's `data` is typed from the OpenAPI 3.2 `itemSchema` (falling back to the media `schema`, then `string`) and `JSON.parse`d when structured: +An operation whose `2xx` response declares `text/event-stream` generates as a typed **async-generator function**. +The client method is exported under its own name, like every other operation. +No flag is required. +The `data` of each event is typed from the OpenAPI 3.2 `itemSchema`. +If `itemSchema` is absent, the type falls back to the media `schema`, then to `string`. +The client applies `JSON.parse` to structured data: ```ts import { streamMessages } from './client.ts'; @@ -377,29 +941,52 @@ for await (const ev of streamMessages()) { } ``` -The stream **auto-reconnects** on a dropped connection, resuming from the last event id via `Last-Event-ID` (backoff honors the server's `retry:`, then `reconnectDelay`, then 1s; capped at 30s). -Tune per call with `{ reconnect: false }` or `{ reconnectDelay: 500 }`. -`break`ing the loop or aborting an `AbortSignal` ends it cleanly (no throw). +The stream **reconnects automatically** after a dropped connection. +It resumes from the last event id with `Last-Event-ID`. +The backoff uses the server's `retry:`, then `reconnectDelay`, then 1 second, with a cap of 30 seconds. +Tune per call with the second argument: `streamMessages({}, { reconnect: false })` or `{ reconnectDelay: 500 }`. +A `break` from the loop, or an aborted `AbortSignal`, ends the stream cleanly with no throw. SSE always throws `ApiError` on a non-2xx initial response, regardless of `--error-mode`. ## Pagination -Pagination is declared, never guessed: describe how your API paginates in `redocly.yaml` under `client.pagination`, or per operation with the `x-redocly-pagination` extension in the description. -The rule fields, the generate-time verification, and the precedence between the convention, `x-redocly-pagination`, and per-operation overrides are documented in the [`client.pagination` reference](../configuration/reference/client.md#pagination-object); there is no CLI flag. -Each paginated operation keeps its one-shot call and gains two async iterators — `.pages(args?, init?)` yielding full pages and `.items(args?, init?)` yielding individual items, typed statically from the response schema. - -Four styles are supported: -`cursor` sends the response's `nextCursor` back in `cursorParam`, stops when it's absent, `null`, or empty, and throws if the server returns the same cursor twice in a row. -For connection-style APIs whose cursor stays non-null on the last page, add the optional `hasMore` pointer (for example `/pageInfo/hasNextPage`) — iteration stops as soon as it resolves to `false`, skipping the follow-up empty request. -`offset` advances `offsetParam` by each page's item count, and `page` increments `offsetParam` by 1; both stop on an empty page. -`link` follows the response's RFC 8288 `Link` header `rel="next"` target (the GitHub pattern) — no advance parameter at all: the runtime merges the target's query params into the next call, so every page goes through the same declared endpoint (auth and middleware apply unchanged, and credentials are never handed to a cross-origin URL); iteration stops when no `rel="next"` is present and throws if the target repeats. -A `link` convention rule applies only to operations whose success response _documents_ a `Link` header; an explicit rule applies regardless but warns when the header is undocumented. -`limitParam` is optional metadata for any style: the iterator never sets it, so pass your page size in `params` yourself. +Pagination is declared, never guessed. +Describe how your API paginates in `redocly.yaml` under `client.pagination`. +Or declare it per operation with the `x-redoclyPagination` extension in the description. +The [`client.pagination` reference](../configuration/reference/client.md#pagination-object) documents the rule fields and the verification at generate time. +It also documents the precedence between the convention, `x-redoclyPagination`, and per-operation overrides. +There is no CLI flag. +Each paginated operation keeps its one-shot call and gains two async iterators. +`.pages(args?, init?)` yields full pages, and `.items(args?, init?)` yields individual items. +Both are typed statically from the response schema. + +The client supports four styles. +`cursor` sends the response's `nextCursor` back in `cursorParam`. +It stops when the cursor is absent, `null`, or empty. +It throws if the server returns the same cursor two times in a row. +Some connection-style APIs keep a non-null cursor on the last page. +For those, add the optional `hasMore` pointer (for example `/pageInfo/hasNextPage`). +Iteration stops as soon as the pointer resolves to `false`, and the client skips the empty follow-up request. + +`offset` advances `offsetParam` by the item count of each page. +`page` increments `offsetParam` by 1. +Both stop on an empty page. + +`link` follows the `rel="next"` target in the response's RFC 8288 `Link` header (the GitHub pattern). +There is no advance parameter. +The runtime merges the target's query parameters into the next call. +Because of this, every page goes through the same declared endpoint: auth and middleware apply unchanged, and the client never gives credentials to a cross-origin URL. +Iteration stops when no `rel="next"` is present, and it throws if the target repeats. +A `link` convention rule applies only to operations whose success response documents a `Link` header. +An explicit rule applies in all cases, but it warns when the header is undocumented. + +`limitParam` is optional metadata for any style. +The iterator never sets it, so pass your page size in `params` yourself. ```ts import { client } from './client.ts'; -for await (const order of client.listOrders.items({ params: { limit: 20 } })) { +for await (const order of client.listOrders.items({ query: { limit: 20 } })) { console.log(order.id); // `order` is `Order` — resolved from the response schema at generate time } @@ -408,38 +995,55 @@ for await (const page of client.listOrders.pages()) { } ``` -The flat free functions keep both iterators. -Note that the flat function itself takes positional arguments, but its `.pages`/`.items` always take the grouped shape — they are the client method's iterators. +`listOrders` and `listOrders.pages` are the same function and its own member, so they take the same input in either argument style. -Resume by passing the advance param in the initial args — iteration starts from there instead of the beginning. -Abort by passing an `AbortSignal`, forwarded to every page request: +To resume, pass the advance parameter in the initial args. +Iteration then starts from that point, not from the beginning. +To abort, pass an `AbortSignal`. +The client forwards it to every page request: ```ts const controller = new AbortController(); for await (const page of client.listOrders.pages( - { params: { cursor: 'c2' } }, // start from a saved cursor (or offset/page number) + { query: { cursor: 'c2' } }, // start from a saved cursor (or offset/page number) { signal: controller.signal } )) { // … } ``` -A failed page always aborts iteration by throwing `ApiError`, even on an `--error-mode result` client. -On a result-mode client, `.pages()` yields raw pages rather than `{ data, error, response }` envelopes — only the one-shot call keeps the envelope — and the throw-mode-only `onError` middleware hook is not invoked. +A failed page always stops iteration with a thrown `ApiError`, even on an `--error-mode result` client. +On a result-mode client, `.pages()` yields raw pages, not `{ data, error, response }` envelopes. +Only the one-shot call keeps the envelope. +The client also does not invoke the `onError` middleware hook, which is throw-mode only. -For shapes the built-in styles don't cover — for example a cursor that travels in the request body or a header — page with a small hand-written helper over the generated call, which stays fully typed end to end (see the [`custom-pagination` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/custom-pagination)). +The built-in styles do not cover every shape, for example a cursor that travels in the request body or in a header. +For those shapes, write a small helper over the generated call. +The helper stays fully typed from end to end. +See the [`custom-pagination` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/custom-pagination). ## TanStack Query factories -The `tanstack-query` generator emits typed TanStack Query v5 factories per operation: - -- `Options(vars, init?)` per query (GET/HEAD) — pass to `useQuery`/`prefetchQuery`. Its `queryFn` forwards TanStack's abort `signal` into the request, so an unmounted or superseded query cancels its network call. -- `InfiniteOptions(vars, init?)` per **paginated** query — pass to `useInfiniteQuery`/`fetchInfiniteQuery`. The `initialPageParam`/`getNextPageParam` pair is compiled from the same [pagination](#pagination) rule that powers `.pages()`/`.items()`, including the `hasMore` stop, so infinite queries need no hand-written `getNextPageParam`. (`link`-style operations are the exception — their next page lives in a response header a `queryFn` cannot see; use the sdk's `.pages()`/`.items()` iterators for those.) -- `QueryKey(vars?)` — with `vars`, the exact key the options use; **without arguments, the invalidation prefix** that matches every cached page and filter of the operation: `queryClient.invalidateQueries({ queryKey: listOrdersQueryKey() })`. -- `Mutation(init?)` per mutation — per-call `RequestOptions` (headers, a retry override) reach the mutation's requests. - -The module-level factories bind the sdk's default `client`. -For an isolated instance (its own credentials, middleware, retry), build a bound set with `createQueryFactories`: +The `tanstack-query` generator emits typed TanStack Query v5 factories for each operation: + +- `Options(vars, init?)` for each query (GET/HEAD). + Pass it to `useQuery`/`prefetchQuery`. + Its `queryFn` forwards TanStack's abort `signal` into the request. + Because of this, an unmounted or superseded query cancels its network call. +- `InfiniteOptions(vars, init?)` for each **paginated** query. + Pass it to `useInfiniteQuery`/`fetchInfiniteQuery`. + The generator compiles the `initialPageParam`/`getNextPageParam` pair from the same [pagination](#pagination) rule that powers `.pages()`/`.items()`, and it includes the `hasMore` stop. + Because of this, infinite queries need no hand-written `getNextPageParam`. + `link`-style operations are the exception, because their next page lives in a response header that a `queryFn` cannot see. + Use the client's `.pages()`/`.items()` iterators for those. +- `QueryKey(vars?)`. + With `vars`, it returns the exact key that the options use. + **Without arguments, it returns the invalidation prefix** that matches every cached page and filter of the operation: `queryClient.invalidateQueries({ queryKey: listOrdersQueryKey() })`. +- `Mutation(init?)` for each mutation. + Per-call `RequestOptions` (headers, a retry override) reach the mutation's requests. + +The module-level factories bind the generated module's default `client`. +For an isolated instance with its own credentials, middleware, and retry, build a bound set with `createQueryFactories`: ```ts import { createClient } from '@redocly/client-generator'; @@ -452,20 +1056,27 @@ const internal = createQueryFactories( useQuery(internal.getOrderOptions({ orderId })); ``` -When several generated APIs share one `QueryClient`, their operationIds can collide (two APIs with a `check` operation would mix caches). -Set `queryKeyPrefix` in the `client` block to namespace every key: `queryKeyPrefix: main` makes the keys `['main', 'check', vars]`. +When several generated APIs share one `QueryClient`, their operationIds can collide. +For example, two APIs with a `check` operation would mix caches. +Set `queryKeyPrefix` in the `client` block to add a namespace to every key. +For example, `queryKeyPrefix: main` makes the keys `['main', 'check', vars]`. ## Format and lint the generated files -The generator prints one canonical style — the TypeScript compiler's printer (four-space indent, double quotes). -If your project's formatter enforces a different style, its check fails on freshly generated files. -Either run your formatter over the output right after generating (for example, as the next step in the same script), or add the generated paths to your formatter's ignore list — generated files are not hand-edited, so reformatting them is churn without review value. +The generator prints one canonical style: the TypeScript compiler's printer, with a four-space indent and double quotes. +If your project's formatter enforces a different style, its check fails on newly generated files. +Run your formatter over the output immediately after generation, for example as the next step in the same script. +Or add the generated paths to your formatter's ignore list. +Generated files are not edited by hand, so a reformat is churn without review value. -Linting is different: the generated code is expected to pass strict lint configurations as-is (no `any`, no non-null assertions, no unused imports). -If your linter flags generated output, [report it](https://github.com/Redocly/redocly-cli/issues) — that is a generator bug, not a style choice. +Linting is different. +The generated code must pass strict lint configurations unchanged: no `any`, no non-null assertions, and no unused imports. +If your linter flags generated output, [report it](https://github.com/Redocly/redocly-cli/issues). +That is a generator bug, not a style choice. ## Resources -- [`generate-client` command](../commands/generate-client.md) — flags, output modes, and invocation. -- [`client` configuration](../configuration/reference/client.md) — the `redocly.yaml` `client` block. -- [Customize client generation](./customize-client-generation.md) — publisher defaults and custom generators. +- **[`generate-client` command](../commands/generate-client.md)** — Learn about the the `generate-client` command's flags, output modes, and invocation +- **[`client` configuration](../configuration/reference/client.md)** — Explore the settings for the `generate-client` command +- **[Customize client generation](./customize-client-generation.md)** — Learn how to control the output of the `generate-client` +- **[Move an app to a generated client](./migrate-to-generated-client.md)** — Replace a hand-written client, one call site at a time diff --git a/docs/@v2/usage-data.md b/docs/@v2/usage-data.md index 7f6302d9d7..3b798a7153 100644 --- a/docs/@v2/usage-data.md +++ b/docs/@v2/usage-data.md @@ -5,28 +5,44 @@ seo: # Usage data and product metrics -Redocly CLI sends a small set of anonymized data to help us understand how the tool is used and improve it. +The Redocly CLI sends a small set of anonymized data to Redocly. +We use this data to understand how you use the tool and to improve it. ## What data is collected -When a command is run, the following data is collected: +When you run a command, the CLI collects this data: -- the command being run -- command exit code -- whether the user is logged into Redocly -- values from `REDOCLY_ENVIRONMENT`, `REDOCLY_CLI_TELEMETRY_METADATA`, and `CI` environment variables -- CLI version -- Node.js and NPM versions +- the command that you run +- the command exit code +- whether the user is logged in to Redocly +- the values of the `REDOCLY_ENVIRONMENT`, `REDOCLY_CLI_TELEMETRY_METADATA`, and `CI` environment variables +- the CLI version +- the Node.js and NPM versions - whether the `redocly.yaml` configuration file exists -- API specification type and version -- names of lint rules that reported errors, warnings, or ignored problems -- Arazzo x-security authentication types -- platform (Linux, macOS, Windows) -- anonymous ID (a randomly generated identifier that doesn't contain personal information) -- command execution time -- whether the CLI runs from a released build or development build - -Values such as file names, organization IDs, and URLs are removed, replaced by just "URL" or "file", etc. +- the API specification type and version +- the names of the lint rules that report errors, warnings, or ignored problems +- the Arazzo x-security authentication types +- for `generate-client`: + - the built-in generators that run + - the count of custom generators + - the names of the package's own exported helpers that a custom generator imports + - the count of APIs that a composed CLI entry (`client.cliOutput`) spans + - a coarse error category if the command fails + If a path-loaded generator has the `eject-generator` provenance header, the CLI also sends the built-in origin and the version that the generator was ejected from (for example `php@0.2.0`). + The CLI never sends the file contents, the file path, or names that the user chose. +- for `eject-generator`: + - the action (`eject`, `update`, `guidance`) + - the name of the built-in generator + - a coarse outcome category (such as `success`, `conflicts` with the conflict count, `already-exists`, or `merge-tool-missing`) + For an `--update` run, the CLI also sends the two `@redocly/client-generator` versions: the version that the file was ejected from, and the installed version. + The CLI never collects the file contents, paths, or names of custom generators. +- the platform (Linux, macOS, Windows) +- an anonymous ID (a randomly generated identifier that contains no personal information) +- the command execution time +- whether the CLI runs from a released build or a development build + +The CLI removes values such as file names, organization IDs, and URLs. +The CLI replaces these values with generic words such as "URL" or "file". ## Opt out of data collection diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml index a7720f844e..0069791079 100644 --- a/docs/@v2/v2.sidebars.yaml +++ b/docs/@v2/v2.sidebars.yaml @@ -18,6 +18,8 @@ page: commands/drift.md - label: eject page: commands/eject.md + - label: eject-generator + page: commands/eject-generator.md - label: generate-arazzo page: commands/generate-arazzo.md - label: generate-client @@ -64,6 +66,8 @@ label: Lint and bundle - label: Use the generated client page: guides/use-generated-client.md + - label: Move an app to a generated client + page: guides/migrate-to-generated-client.md - label: Customize client generation page: guides/customize-client-generation.md - label: Hide internal APIs diff --git a/package-lock.json b/package-lock.json index 0adbe0a1ab..81be612fbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "tsx": "^4.19.3", "typescript": "6.0.2", "typescript7": "npm:typescript@7.0.2", + "valibot": "^1.4.2", "vitest": "^4.1.8", "zod": "^4.0.0" }, @@ -10534,6 +10535,21 @@ "dev": true, "license": "MIT" }, + "node_modules/valibot": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/vfile": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz", diff --git a/package.json b/package.json index a02bac13e2..eb6aede0b9 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test": "npm run compile && npm run typecheck && npm run unit && npm run e2e", "unit": "VITEST_SUITE=unit vitest run", "e2e": "VITEST_SUITE=e2e vitest run", + "client-generators": "VITEST_SUITE=client-generators vitest run", "smoke:rebilly": "VITEST_SUITE=smoke-rebilly vitest run", "format": "oxfmt .", "format:check": "oxfmt --check .", @@ -79,6 +80,7 @@ "tsx": "^4.19.3", "typescript": "6.0.2", "typescript7": "npm:typescript@7.0.2", + "valibot": "^1.4.2", "vitest": "^4.1.8", "zod": "^4.0.0" }, diff --git a/packages/cli/scripts/build.mjs b/packages/cli/scripts/build.mjs index 305284ca48..5363bbc3ec 100644 --- a/packages/cli/scripts/build.mjs +++ b/packages/cli/scripts/build.mjs @@ -1,5 +1,5 @@ import { build } from 'esbuild'; -import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -93,6 +93,12 @@ writeFileSync( `Third-party software bundled in @redocly/cli\n\n${sections.join('\n\n')}\n` ); +cpSync( + path.join(packageDir, '..', 'client-generator', 'eject-assets'), + path.join(packageDir, 'lib', 'eject-assets'), + { recursive: true } +); + function findLicenseText(pkgRoot) { for (const filename of ['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'LICENCE', 'LICENCE.md']) { const licensePath = path.join(pkgRoot, filename); diff --git a/packages/cli/src/commands/__tests__/eject-generator.test.ts b/packages/cli/src/commands/__tests__/eject-generator.test.ts new file mode 100644 index 0000000000..e2dcdeb1ab --- /dev/null +++ b/packages/cli/src/commands/__tests__/eject-generator.test.ts @@ -0,0 +1,302 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { outdent } from 'outdent'; + +import { ejectGeneratorTelemetry } from '../../utils/client-generator-telemetry.js'; +import type { CommandArgs } from '../../wrapper.js'; +import { + handleEjectGenerator, + packedAssets, + threeWayMerge, + wireConfig, +} from '../eject-generator.js'; + +const baseArgs = { version: '0.0.0', config: undefined } as unknown as Omit< + CommandArgs>, + 'argv' +>; + +function reset() { + for (const key of Object.keys(ejectGeneratorTelemetry)) { + delete ejectGeneratorTelemetry[key as keyof typeof ejectGeneratorTelemetry]; + } +} + +describe('wireConfig', () => { + const wire = (source: string): string => { + const dir = mkdtempSync(join(tmpdir(), 'redocly-wire-config-')); + const configPath = join(dir, 'redocly.yaml'); + writeFileSync(configPath, source, 'utf-8'); + try { + expect(wireConfig(configPath, 'php', './generators/php.mjs')).toBe(true); + return readFileSync(configPath, 'utf-8'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + + it('replaces a bare built-in name so the next run has no name collision', () => { + expect( + wire(outdent` + client: + generators: + - php + - typescript + `) + ).toBe(outdent` + client: + generators: + - ./generators/php.mjs + - typescript + `); + expect(wire('client:\n generators: [php, typescript]\n')).toBe( + 'client:\n generators: [./generators/php.mjs, typescript]\n' + ); + }); + + it('appends when the built-in name is not listed', () => { + expect( + wire(outdent` + client: + generators: + - typescript + `) + ).toBe(outdent` + client: + generators: + - typescript + - ./generators/php.mjs + `); + }); + + it('inserts the generators list when the client block has none', () => { + expect( + wire(outdent` + client: + runtime: package + apis: + cafe: + root: ./openapi.yaml + `) + ).toBe(outdent` + client: + generators: + - ./generators/php.mjs + runtime: package + apis: + cafe: + root: ./openapi.yaml + `); + }); + + it('wires despite a comment that mentions the path, and is idempotent once listed', () => { + // A mention outside the list (a comment, a longer path) is not wiring. + expect( + wire(outdent` + # was: ./generators/php.mjs + client: + generators: + - typescript + `) + ).toBe(outdent` + # was: ./generators/php.mjs + client: + generators: + - typescript + - ./generators/php.mjs + `); + // A real list entry is — the file stays unchanged. + const wired = outdent` + client: + generators: + - ./generators/php.mjs + `; + expect(wire(wired)).toBe(wired); + }); + + it('reads through comments in the list: inline ones survive a replace, entries below comment lines count', () => { + expect( + wire(outdent` + client: + generators: + # our copies: + - php # ours + - typescript + `) + ).toBe(outdent` + client: + generators: + # our copies: + - ./generators/php.mjs # ours + - typescript + `); + // An already-wired entry behind a comment line is found, not duplicated. + const wired = outdent` + client: + generators: + - typescript + # ejected: + - ./generators/php.mjs + `; + expect(wire(wired)).toBe(wired); + }); + + it('prints the snippet instead when an api has its own client block', () => { + // `forAlias` replaces the top-level `client` with the api's block wholesale, so + // inserting top-level keys would report "wired" while generation ignores them. + const dir = mkdtempSync(join(tmpdir(), 'redocly-wire-config-')); + const configPath = join(dir, 'redocly.yaml'); + writeFileSync( + configPath, + outdent` + apis: + cafe: + root: ./openapi.yaml + client: + argsStyle: grouped + `, + 'utf-8' + ); + try { + expect(wireConfig(configPath, 'php', './generators/php.mjs')).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('appends a client block when the config has none', () => { + expect( + wire( + outdent` + apis: + cafe: + root: ./openapi.yaml + clientOutput: ./src/client.ts + ` + '\n' + ) + ).toBe( + outdent` + apis: + cafe: + root: ./openapi.yaml + clientOutput: ./src/client.ts + client: + generators: + - ./generators/php.mjs + ` + '\n' + ); + }); +}); + +describe('threeWayMerge', () => { + beforeEach(reset); + + it('merges cleanly and counts conflicts', () => { + const base = 'a\nb\nc\nd\ne\n'; + expect(threeWayMerge('A\nb\nc\nd\ne\n', base, 'a\nb\nc\nd\nE\n')).toEqual({ + merged: 'A\nb\nc\nd\nE\n', + conflicts: 0, + }); + const conflicted = threeWayMerge('a\nyours\nc\nd\ne\n', base, 'a\ntheirs\nc\nd\ne\n'); + expect(conflicted.conflicts).toBe(1); + expect(conflicted.merged).toContain('<<<<<<<'); + }); + + it("keeps the user's copy when git merge-file errors instead of counting conflicts", () => { + // Binary (NUL-byte) content makes `git merge-file` exit 255 with empty stdout — + // that must surface as an error, never as "255 conflicts" written over the file. + expect(() => threeWayMerge('customized\0', 'base\0', 'updated\0')).toThrow( + /could not merge the update/ + ); + expect(ejectGeneratorTelemetry.eject_generator_outcome).toBe('merge-failed'); + }); +}); + +describe('eject telemetry (coarse categories only)', () => { + beforeEach(reset); + + it('a framework variant records the allowlisted name and a guidance action', async () => { + // Every generator ejects now; only the tanstack-query framework variants are guidance, + // since they are that generator with one argument changed. + await handleEjectGenerator({ + ...baseArgs, + argv: { generator: 'tanstack-query-vue' }, + } as CommandArgs); + expect(ejectGeneratorTelemetry).toEqual({ + eject_generator_action: 'guidance', + eject_generator_name: 'tanstack-query-vue', + eject_generator_outcome: 'success', + }); + }); + + it('a failure we did not account for still records an outcome', async () => { + // A destination that is a FILE: writing into it throws ENOTDIR, which no branch + // categorizes. (Reading the assets from source used to be the trigger here; it is a + // supported path now that they resolve from the package that owns them.) + const blocked = mkdtempSync(join(tmpdir(), 'eject-blocked-')); + writeFileSync(join(blocked, 'generators'), 'not a directory', 'utf-8'); + try { + await expect( + handleEjectGenerator({ + ...baseArgs, + argv: { generator: 'php', dir: join(blocked, 'generators') }, + } as CommandArgs) + ).rejects.toThrow(); + expect(ejectGeneratorTelemetry).toEqual({ + eject_generator_action: 'eject', + eject_generator_name: 'php', + eject_generator_outcome: 'unexpected-error', + }); + } finally { + rmSync(blocked, { recursive: true, force: true }); + } + }); + + it('an unknown generator records the outcome but never the user-supplied name', async () => { + await expect( + handleEjectGenerator({ + ...baseArgs, + argv: { generator: 'my-secret-internal-api' }, + } as CommandArgs) + ).rejects.toThrow(/Unknown generator/); + expect(ejectGeneratorTelemetry.eject_generator_outcome).toBe('unknown-generator'); + expect(ejectGeneratorTelemetry.eject_generator_name).toBeUndefined(); + }); +}); + +const clientGeneratorDir = resolve( + dirname(fileURLToPath(import.meta.url)), + '../../../../client-generator' +); + +describe('packedAssets', () => { + it('reads the generator and its skills out of a packed @redocly/client-generator', () => { + // 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/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') + ); + expect(assets.get(members[1])).toBe( + readFileSync(join(clientGeneratorDir, 'eject-assets/skills/php-generator/SKILL.md'), 'utf-8') + ); + // A member the packed version does not ship is absent, so the caller falls back per file. + expect(assets.has(members[2])).toBe(false); + // `npm pack` on a directory runs that package's prepare script, so give it room. + }, 180_000); + + it('returns nothing when the spec cannot be packed, so the caller can fall back', () => { + expect( + packedAssets('@redocly/client-generator@0.0.0-does-not-exist', [ + 'package/eject-assets/generators/php.mjs', + ]).size + ).toBe(0); + }); +}); diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts new file mode 100644 index 0000000000..f2fa3d9b8d --- /dev/null +++ b/packages/cli/src/commands/eject-generator.ts @@ -0,0 +1,598 @@ +import { HandledError, isPlainObject, logger, parseYaml } from '@redocly/openapi-core'; +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as semver from 'semver'; + +import { ejectGeneratorTelemetry } from '../utils/client-generator-telemetry.js'; +import { type CommandArgs } from '../wrapper.js'; + +export type EjectGeneratorCommandArgv = { + generator?: string; + config?: string; + dir?: string; + force?: boolean; + update?: boolean; +}; + +/** Every built-in generator ships as a vendorable asset. */ +export const EJECTABLE = new Set([ + 'python', + 'go', + 'php', + 'typescript', + 'zod', + 'mock', + 'swr', + 'tanstack-query', + 'transformers', + 'cli', +]); + +/** + * The tanstack-query framework variants share one implementation — the framework is a + * single argument in the ejected file — so they point at the base generator instead of + * shipping four near-identical bundles. + */ +export const FRAMEWORK_VARIANTS = new Map([ + ['tanstack-query-vue', 'vue'], + ['tanstack-query-svelte', 'svelte'], + ['tanstack-query-solid', 'solid'], +]); + +/** The packages an ejected generator imports; recorded as devDependencies. */ +const TOOLKIT_PACKAGE = '@redocly/client-generator'; +const DOCS_URL = 'https://redocly.com/docs/cli/commands/eject-generator'; +const CORE_PACKAGE = '@redocly/openapi-core'; + +const AGENTS_BEGIN = + ''; +const AGENTS_END = ''; + +/** + * Where the shipped generator sources and skills live. The published CLI bundles everything + * and has no `node_modules`, so its build copies the assets beside the bundle; running from + * `src` there is no such copy, and the assets are read from the package that owns them. + */ +export function ejectAssetsDir(): string { + const bundled = fileURLToPath(new URL('./eject-assets/', import.meta.url)); + if (existsSync(join(bundled, 'generators'))) return bundled; + // `resolve` lands on the toolkit's entry module; the assets sit at its package root. + const entry = createRequire(import.meta.url).resolve(TOOLKIT_PACKAGE); + const owned = join(dirname(entry), '..', 'eject-assets'); + if (existsSync(join(owned, 'generators'))) return owned; + // Only reachable in a checkout whose generator bundles have never been built. Saying so + // beats an ENOENT stack trace naming a path the reader has no reason to expect. + throw new HandledError( + `\n❌ The ejectable generator sources are missing from ${TOOLKIT_PACKAGE}.\n` + + ` In a checkout of the CLI, build them first: npm run prepare -w ${TOOLKIT_PACKAGE}\n` + ); +} + +/** Copy a shipped skill into the repo's `.claude/skills//SKILL.md`, overwriting ours. */ +function dropSkill(skill: string, assetsDir: string): string { + const target = join(process.cwd(), '.claude', 'skills', skill, 'SKILL.md'); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync( + target, + readFileSync(join(assetsDir, 'skills', skill, 'SKILL.md'), 'utf-8'), + 'utf-8' + ); + return relative(process.cwd(), target); +} + +/** + * Drop or refresh the pointer at `/AGENTS.md`: it says what these files are and + * where their design lives, so the directory explains itself to an agent that opens it + * without the skills loaded. Managed between markers; anything the user adds is kept. + */ +function dropPointer(dir: string, ejected: string[]): void { + const lines = [ + '# Ejected client generators', + '', + 'These files are Redocly client generators you own; `redocly generate-client` runs them.', + 'Their design and the authoring toolkit are agent skills — edit the skill first, then make', + 'the code match, and never hand-edit generated client output:', + '', + '- `.claude/skills/client-generators/SKILL.md` — the API model, the helpers, the loop.', + ...ejected.map( + (name) => + `- \`.claude/skills/${name}-generator/SKILL.md\` — the \`${name}\` generator's design.` + ), + ]; + const managed = `${AGENTS_BEGIN}\n\n${lines.join('\n')}\n\n${AGENTS_END}\n`; + const target = join(dir, 'AGENTS.md'); + if (!existsSync(target)) { + writeFileSync(target, managed, 'utf-8'); + return; + } + const current = readFileSync(target, 'utf-8'); + const begin = current.indexOf(AGENTS_BEGIN); + const end = current.indexOf(AGENTS_END); + if (begin === -1 || end === -1) { + logger.warn( + `eject-generator: ${target} exists without the managed markers — leaving it untouched.\n` + ); + return; + } + writeFileSync( + target, + current.slice(0, begin) + managed.trimEnd() + current.slice(end + AGENTS_END.length), + 'utf-8' + ); +} + +/** 3-way merge via `git merge-file`; returns the merged text and the conflict count. */ +export function threeWayMerge( + customized: string, + base: string, + updated: string +): { merged: string; conflicts: number } { + const scratch = mkdtempSync(join(tmpdir(), 'redocly-eject-merge-')); + const paths = { + ours: join(scratch, '.merge-ours'), + base: join(scratch, '.merge-base'), + theirs: join(scratch, '.merge-theirs'), + }; + writeFileSync(paths.ours, customized, 'utf-8'); + writeFileSync(paths.base, base, 'utf-8'); + writeFileSync(paths.theirs, updated, 'utf-8'); + const result = spawnSync( + 'git', + [ + 'merge-file', + '-p', + '-L', + 'yours', + '-L', + 'ejected-from', + '-L', + 'update', + paths.ours, + paths.base, + paths.theirs, + ], + { encoding: 'utf-8' } + ); + rmSync(scratch, { recursive: true, force: true }); + if (result.error || result.status === null) { + ejectGeneratorTelemetry.eject_generator_outcome = 'merge-tool-missing'; + throw new HandledError( + '\n❌ `--update` needs `git` on PATH for the three-way merge. Alternative: eject to a temporary directory and diff by hand.\n' + ); + } + // `git merge-file` exits with the conflict count truncated to 127; anything above + // that is its negative error exit, where stdout is empty — writing it would destroy + // the user's copy. + if (result.status > 127) { + ejectGeneratorTelemetry.eject_generator_outcome = 'merge-failed'; + throw new HandledError( + `\n❌ \`git merge-file\` could not merge the update (your copy is untouched): ${result.stderr.trim()}\n` + ); + } + return { merged: result.stdout, conflicts: result.status }; +} + +/** The toolkit version an ejected file records in its provenance header. */ +function recordedVersion(ejected: string): string | undefined { + return /Ejected from @redocly\/client-generator@(\S+)/.exec(ejected)?.[1]; +} + +/** + * Assets as a past version shipped them, taken from that version's package on the + * registry — the header records which version to ask for, so the merge base needs + * nothing committed. `spec` is anything npm can pack (a version spec; a directory in + * tests). Members that cannot be read are simply absent from the result, so the caller + * falls back per file instead of merging against the wrong base. + */ +export function packedAssets(spec: string, members: string[]): Map { + const scratch = mkdtempSync(join(tmpdir(), 'redocly-eject-base-')); + const extracted = new Map(); + try { + const packed = spawnSync('npm', ['pack', spec, '--pack-destination', scratch], { + encoding: 'utf-8', + }); + if (packed.status !== 0) return extracted; + const tarball = readdirSync(scratch).find((file) => file.endsWith('.tgz')); + if (tarball === undefined) return extracted; + for (const member of members) { + const extraction = spawnSync('tar', ['-xzf', join(scratch, tarball), '-C', scratch, member], { + encoding: 'utf-8', + }); + if (extraction.status === 0) + extracted.set(member, readFileSync(join(scratch, member), 'utf-8')); + } + return extracted; + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} + +const generatorMember = (name: string) => `package/eject-assets/generators/${name}.mjs`; +const skillMember = (skill: string) => `package/eject-assets/skills/${skill}/SKILL.md`; + +/** + * 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 + * the skill the recorded version shipped, theirs is the current one. Without a base (a + * legacy `.pristine` eject, a failed fetch), an edited copy is kept and the new skill + * lands beside it as `SKILL.md.new`. Returns the conflict count. + */ +function updateSkill(skill: string, assetsDir: string, baseSkill: string | undefined): number { + const target = join(process.cwd(), '.claude', 'skills', skill, 'SKILL.md'); + const updated = readFileSync(join(assetsDir, 'skills', skill, 'SKILL.md'), 'utf-8'); + if (!existsSync(target)) { + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, updated, 'utf-8'); + return 0; + } + const current = readFileSync(target, 'utf-8'); + if (current === updated) return 0; + if (baseSkill === undefined) { + writeFileSync(`${target}.new`, updated, 'utf-8'); + logger.warn( + `${relative(process.cwd(), target)} was edited and has no merge base — the new skill is beside it as SKILL.md.new.\n` + ); + return 0; + } + const { merged, conflicts } = threeWayMerge(current, baseSkill, updated); + writeFileSync(target, merged, 'utf-8'); + return conflicts; +} + +/** 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`))); +} + +/** + * Record `@redocly/client-generator` in the project's devDependencies — the ejected file + * imports the authoring toolkit from it. Installing stays the user's call; this only makes + * the requirement part of the project so a fresh clone or CI gets it. With `refresh` (the + * `--update` path), a recorded range that no longer covers `version` is moved to + * `^version` wherever the project keeps it — the merged file targets the new toolkit. + */ +function wireDependency( + packages: Record, + refresh = false +): 'added' | 'updated' | 'present' | 'no-package-json' { + const manifestPath = join(process.cwd(), 'package.json'); + if (!existsSync(manifestPath)) return 'no-package-json'; + const manifestSource = readFileSync(manifestPath, 'utf-8'); + const manifest = JSON.parse(manifestSource) as { + dependencies?: Record; + devDependencies?: Record; + }; + let outcome: 'added' | 'updated' | 'present' = 'present'; + const missing: Record = {}; + for (const [name, version] of Object.entries(packages)) { + const section = + manifest.devDependencies?.[name] !== undefined + ? manifest.devDependencies + : manifest.dependencies?.[name] !== undefined + ? manifest.dependencies + : undefined; + if (section === undefined) { + missing[name] = `^${version}`; + outcome = 'added'; + } else if ( + refresh && + !(semver.validRange(section[name]) !== null && semver.satisfies(version, section[name])) + ) { + section[name] = `^${version}`; + if (outcome === 'present') outcome = 'updated'; + } + } + if (outcome === 'present') return 'present'; + if (Object.keys(missing).length > 0) { + const devDependencies = { ...manifest.devDependencies, ...missing }; + manifest.devDependencies = Object.fromEntries( + Object.entries(devDependencies).sort(([left], [right]) => left.localeCompare(right)) + ); + } + const indent = /^([ \t]+)"/m.exec(manifestSource)?.[1] ?? ' '; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, indent)}\n`, 'utf-8'); + return outcome; +} + +/** + * The config has no top-level `generators:` list — add one, unless an api's own `client` + * block would replace it wholesale (`forAlias`); then the caller prints the snippet and + * the user picks the block. + */ +function insertGeneratorsList( + configPath: string, + source: string, + lines: string[], + clientLine: number, + entry: string +): boolean { + const parsed = parseYaml(source); + const apis = isPlainObject(parsed) && isPlainObject(parsed.apis) ? parsed.apis : {}; + if (Object.values(apis).some((api) => isPlainObject(api) && isPlainObject(api.client))) { + return false; + } + if (clientLine === -1) { + if (/^client:/m.test(source)) return false; // `client: {...}` or similar — not a shape we edit + const separator = source === '' || source.endsWith('\n') ? '' : '\n'; + writeFileSync( + configPath, + `${source}${separator}client:\n generators:\n - ${entry}\n`, + 'utf-8' + ); + return true; + } + lines.splice(clientLine + 1, 0, ' generators:', ` - ${entry}`); + writeFileSync(configPath, lines.join('\n'), 'utf-8'); + return true; +} + +/** + * Point `client.generators` at the ejected file, editing the text so comments and + * formatting survive. A bare `` entry is replaced — keeping both would collide on + * the name the ejected file takes over. A shape this can't extend without guessing + * returns false, and the caller prints the snippet instead of reshaping someone's config. + */ +export function wireConfig(configPath: string | undefined, name: string, entry: string): boolean { + if (configPath === undefined || !existsSync(configPath)) return false; + const source = readFileSync(configPath, 'utf-8'); + const isItem = (value: string) => (item: string) => + item === value || item === `'${value}'` || item === `"${value}"`; + const isNameEntry = isItem(name); + const isPathEntry = isItem(entry); + const lines = source.split('\n'); + const clientLine = lines.findIndex((line) => /^client:\s*$/.test(line)); + let generatorsLine = + clientLine === -1 + ? -1 + : lines.findIndex((line, index) => index > clientLine && /^\s+generators:/.test(line)); + // A `generators:` beyond a dedented line belongs to another block. + if ( + generatorsLine !== -1 && + lines.slice(clientLine + 1, generatorsLine).some((line) => /^\S/.test(line)) + ) { + generatorsLine = -1; + } + if (generatorsLine === -1) { + return insertGeneratorsList(configPath, source, lines, clientLine, entry); + } + + const flow = lines[generatorsLine].match(/^(\s+generators:\s*\[)(.*)\]\s*$/); + if (flow !== null) { + const items = flow[2] + .split(',') + .map((item) => item.trim()) + .filter((item) => item !== ''); + if (items.some(isPathEntry)) return true; + const nameEntry = items.findIndex(isNameEntry); + if (nameEntry === -1) items.push(entry); + else items[nameEntry] = entry; + lines[generatorsLine] = `${flow[1]}${items.join(', ')}]`; + writeFileSync(configPath, lines.join('\n'), 'utf-8'); + return true; + } + if (!/^\s+generators:\s*$/.test(lines[generatorsLine])) return false; + let lastItem = generatorsLine; + let itemIndent = `${lines[generatorsLine].match(/^\s+/)![0]} `; + for (let index = generatorsLine + 1; index < lines.length; index++) { + if (/^\s*(#|$)/.test(lines[index])) continue; + const item = lines[index].match(/^(\s+)- (.*?)\s*$/); + if (item === null) break; + const comment = item[2].match(/\s+#.*$/)?.[0] ?? ''; + const value = comment === '' ? item[2] : item[2].slice(0, -comment.length); + if (isPathEntry(value)) return true; + if (isNameEntry(value)) { + lines[index] = `${item[1]}- ${entry}${comment}`; + writeFileSync(configPath, lines.join('\n'), 'utf-8'); + return true; + } + lastItem = index; + itemIndent = item[1]; + } + lines.splice(lastItem + 1, 0, `${itemIndent}- ${entry}`); + writeFileSync(configPath, lines.join('\n'), 'utf-8'); + return true; +} + +/** + * 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. + */ +function updateEjectedGenerator({ + name, + asset, + toolkitVersion, + assetsDir, + dir, + target, + printedTarget, +}: { + name: string; + asset: string; + toolkitVersion: string; + assetsDir: string; + dir: string; + target: string; + printedTarget: string; +}): void { + if (!existsSync(target)) { + ejectGeneratorTelemetry.eject_generator_outcome = 'missing-target'; + throw new HandledError( + `\n❌ Nothing to update: ${printedTarget} does not exist. Eject first.\n` + ); + } + // Legacy ejects left a `.pristine` snapshot behind; it still works as the merge base. + const legacyBase = join(dir, '.pristine', `${name}.mjs`); + const customized = readFileSync(target, 'utf-8'); + const from = recordedVersion(customized); + // The header is user-editable text, so the version is recorded only when it parses. + 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: the generator plus both skills it shipped with. + const packed = + existsSync(legacyBase) || from === toolkitVersion || from === undefined + ? new Map() + : packedAssets(`${TOOLKIT_PACKAGE}@${from}`, [ + generatorMember(name), + skillMember('client-generators'), + skillMember(`${name}-generator`), + ]); + const base = existsSync(legacyBase) + ? readFileSync(legacyBase, 'utf-8') + : from === toolkitVersion + ? asset + : packed.get(generatorMember(name)); + if (base === undefined) { + ejectGeneratorTelemetry.eject_generator_outcome = 'missing-base'; + const sideBySide = `${target}.new`; + writeFileSync(sideBySide, asset, 'utf-8'); + throw new HandledError( + `\n❌ Could not read the version this file was ejected from (${from ?? 'not recorded in its header'}), so there is no merge base.\n` + + ` The current generator is written to ${relative(process.cwd(), sideBySide)} — diff it against your copy and merge by hand.\n` + ); + } + const { merged, conflicts } = threeWayMerge(customized, base, asset); + writeFileSync(target, merged, 'utf-8'); + if (existsSync(legacyBase)) { + logger.info( + `Used ${relative(process.cwd(), legacyBase)} as the merge base. Later updates read the version from the file's header, so you can delete that .pristine directory.\n` + ); + } + // The skills are edit-first files too, so they merge the same way the generator did. + 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)); + // The merged file targets the new toolkit; a range recorded at eject time may not. + 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'; + if (totalConflicts > 0) { + ejectGeneratorTelemetry.eject_generator_conflicts = totalConflicts; + logger.warn( + `Updated ${printedTarget} with ${totalConflicts} conflict(s)${ + skillConflicts > 0 ? ' (some in .claude/skills)' : '' + } — resolve the <<<<<<< markers, then regenerate.\n` + ); + } else { + logger.info(`Updated ${printedTarget} cleanly.\n`); + } +} + +export const handleEjectGenerator = async ({ + argv, + config, +}: CommandArgs) => { + const name = argv.generator ?? ''; + // Coarse usage telemetry: our command action, an ALLOWLISTED built-in name, and the + // outcome category — never user paths, file contents, or user-chosen names. + ejectGeneratorTelemetry.eject_generator_action = argv.update ? 'update' : 'eject'; + if (EJECTABLE.has(name) || FRAMEWORK_VARIANTS.has(name)) { + ejectGeneratorTelemetry.eject_generator_name = name; + } + // Every path that finishes overwrites this, so it survives only an unaccounted throw. + ejectGeneratorTelemetry.eject_generator_outcome = 'unexpected-error'; + const framework = FRAMEWORK_VARIANTS.get(name); + if (framework !== undefined) { + ejectGeneratorTelemetry.eject_generator_action = 'guidance'; + ejectGeneratorTelemetry.eject_generator_outcome = 'success'; + logger.info( + `\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` + ); + return; + } + if (!EJECTABLE.has(name)) { + ejectGeneratorTelemetry.eject_generator_outcome = 'unknown-generator'; + throw new HandledError( + `\n❌ Unknown generator "${name}". Ejectable generators: ${[...EJECTABLE].join(', ')}.\n` + ); + } + + const assetsDir = ejectAssetsDir(); + const asset = readFileSync(join(assetsDir, 'generators', `${name}.mjs`), 'utf-8'); + // 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`); + const printedTarget = relative(process.cwd(), target) || target; + + if (argv.update) { + updateEjectedGenerator({ name, asset, toolkitVersion, assetsDir, dir, target, printedTarget }); + return; + } + + if (existsSync(target) && !argv.force) { + ejectGeneratorTelemetry.eject_generator_outcome = 'already-exists'; + throw new HandledError( + `\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'); + const authoringSkill = dropSkill('client-generators', assetsDir); + const designSkill = dropSkill(`${name}-generator`, assetsDir); + dropPointer(dir, ejectedIn(dir)); + // Config-file entries resolve against the config's directory, so the wired path is + // relative to it — real paths on both sides, so a symlink doesn't skew the walk. + const configEntry = `./${relative( + config.configPath === undefined ? process.cwd() : realpathSync(dirname(config.configPath)), + realpathSync(target) + ) + .split('\\') + .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 wired = wireConfig(config.configPath, name, configEntry); + logger.info( + `Ejected the "${name}" generator to ${printedTarget}.\n` + + (dependency === 'added' + ? `Added ${TOOLKIT_PACKAGE} to devDependencies (the ejected file imports its toolkit) — run your installer.\n` + : dependency === 'no-package-json' + ? `The ejected file imports its toolkit from ${TOOLKIT_PACKAGE} — install it: npm install --save-dev ${TOOLKIT_PACKAGE}\n` + : '') + + (needsCore + ? `It also imports ${CORE_PACKAGE} (a dependency of the toolkit) — add it explicitly if your package manager does not hoist.\n` + : '') + + (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` + + ` client:\n generators:\n - ${configEntry}\n\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` + + `Reference: ${DOCS_URL}\n` + ); + // Last, so wiring the dependency or the config entry failing is not reported as success. + ejectGeneratorTelemetry.eject_generator_outcome = 'success'; +}; diff --git a/packages/cli/src/commands/generate-client.ts b/packages/cli/src/commands/generate-client.ts index 47212e5468..13ea826a11 100644 --- a/packages/cli/src/commands/generate-client.ts +++ b/packages/cli/src/commands/generate-client.ts @@ -1,8 +1,28 @@ -import { type GenerateClientConfig } from '@redocly/client-generator'; +import type { + GenerateClientConfig, + generateClient as generateClientFunction, + mergeConfig as mergeConfigFunction, +} from '@redocly/client-generator'; import { HandledError, isPlainObject, logger, pluralize } from '@redocly/openapi-core'; import { blue, gray, yellow } from 'colorette'; -import { basename, dirname, extname, isAbsolute, resolve as resolvePath } from 'node:path'; +import { readFileSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { + basename, + dirname, + extname, + isAbsolute, + relative, + resolve as resolvePath, +} from 'node:path'; +import { + BUILTIN_GENERATOR_NAMES, + categorizeGenerateClientError, + collectToolkitImports, + generateClientTelemetry, + parseEjectedProvenance, +} from '../utils/client-generator-telemetry.js'; import { getFallbackApisOrExit } from '../utils/miscellaneous.js'; import { type CommandArgs } from '../wrapper.js'; @@ -14,11 +34,13 @@ export type GenerateClientCommandArgv = { 'output-mode'?: 'single' | 'split'; runtime?: 'inline' | 'package'; 'import-ext'?: 'js' | 'ts'; + 'go-package'?: string; 'args-style'?: 'flat' | 'grouped'; 'error-mode'?: 'throw' | 'result'; 'date-type'?: 'string' | 'Date'; 'mock-data'?: 'static' | 'faker'; 'mock-seed'?: number; + docs?: boolean; generator?: string[]; setup?: string; }; @@ -44,8 +66,6 @@ function fileNameFor(name: string): string { return `${name.replace(/[\\/]/g, '_')}.client.ts`; } -// Accepts an absolute http(s) URL or a root-relative path; rejects bare hostnames, -// protocol-relative `//host`, and non-http(s) schemes. function isValidServerUrl(value: string): boolean { if (value.startsWith('//')) return false; if (value.startsWith('/')) return true; @@ -57,11 +77,30 @@ function isValidServerUrl(value: string): boolean { } } +type ClientGeneratorToolkit = { + generateClient: typeof generateClientFunction; + mergeConfig: typeof mergeConfigFunction; + helperNames: readonly string[]; +}; + +type GenerationRun = { + config: CommandArgs['config']; + configDir: string; + cliFlags: GenerateClientConfig; + outputFlag: string | undefined; + toolkit: ClientGeneratorToolkit; + /** Every path this run wrote or will write — collision guard across apis and the composed entry. */ + seenOutputs: Set; + /** Every api that emits a cli module, gathered for the composed entry (client.cliOutput). */ + composable: Array<{ alias: string; cliPath: string }>; +}; + export async function handleGenerateClient({ argv, config, }: CommandArgs) { - const { generateClient, mergeConfig } = await import('@redocly/client-generator'); + const { AUTHORING_HELPER_NAMES, generateClient, mergeConfig } = + await import('@redocly/client-generator'); const configDir = config.configPath ? dirname(config.configPath) : process.cwd(); @@ -70,14 +109,13 @@ export async function handleGenerateClient({ outputMode: argv['output-mode'], runtime: argv.runtime, importExt: argv['import-ext'], + goPackage: argv['go-package'], argsStyle: argv['args-style'], errorMode: argv['error-mode'], dateType: argv['date-type'], mockData: argv['mock-data'], mockSeed: argv['mock-seed'], - // Like `setup` below: flag paths resolve against the cwd, while config-file entries - // resolve against the config dir (in `resolveGenerators`). Package specifiers and - // built-in names pass through. + docs: argv.docs, generators: argv.generator?.map((specifier) => specifier.startsWith('.') ? resolvePath(specifier) : specifier ), @@ -108,63 +146,187 @@ export async function handleGenerateClient({ config ); - const seenOutputs = new Set(); - - for (const { path, alias } of entrypoints) { - const name = alias ?? basename(path, extname(path)); - // `forAlias` layers the api's entry over the root config, so `client` is the - // per-api block when the api declares one and the top-level block otherwise. - const aliasConfig = config.forAlias(alias); - const { client, clientOutput } = aliasConfig.resolvedConfig; - const clientBlock = resolveSetup( - (isPlainObject(client) ? client : {}) as GenerateClientConfig, - configDir - ); - const clientConfig = mergeConfig(clientBlock, cliFlags); + const run: GenerationRun = { + config, + configDir, + cliFlags, + outputFlag: argv.output, + toolkit: { generateClient, mergeConfig, helperNames: AUTHORING_HELPER_NAMES }, + seenOutputs: new Set(), + composable: [], + }; - const outputPath = - argv.output !== undefined - ? resolvePath(argv.output) - : clientOutput !== undefined - ? resolvePath(configDir, clientOutput) - : resolvePath(configDir, fileNameFor(name)); + for (const entry of entrypoints) { + await generateApiClient(entry, run); + } - if (!outputPath.endsWith('.ts')) { - throw new HandledError( - `\n❌ output must point at a TypeScript file (ending in .ts).\n Got: ${outputPath}\n` - ); - } - if (seenOutputs.has(outputPath)) { - throw new HandledError( - `\n❌ Two APIs resolve to the same output path: ${outputPath}.\n Give each api a distinct \`clientOutput\`.\n` - ); + // Top-level `client.cliOutput` only — a per-api block composes nothing — and only for + // the run-everything form, where all the modules exist. + const topLevelClient = ( + isPlainObject(config.resolvedConfig.client) ? config.resolvedConfig.client : {} + ) as GenerateClientConfig; + if ( + topLevelClient.cliOutput !== undefined && + argv.api === undefined && + run.composable.length > 0 + ) { + await writeComposedCliEntry(topLevelClient.cliOutput, run); + } +} + +async function generateApiClient( + entry: { path: string; alias?: string }, + { config, configDir, cliFlags, outputFlag, toolkit, seenOutputs, composable }: GenerationRun +): Promise { + const { path, alias } = entry; + const name = alias ?? basename(path, extname(path)); + const aliasConfig = config.forAlias(alias); + const { client, clientOutput } = aliasConfig.resolvedConfig; + const clientBlock = resolveSetup( + (isPlainObject(client) ? client : {}) as GenerateClientConfig, + configDir + ); + const clientConfig = toolkit.mergeConfig(clientBlock, cliFlags); + collectGeneratorUsage(clientConfig.generators ?? [], toolkit.helperNames, configDir); + + const outputPath = + outputFlag !== undefined + ? resolvePath(outputFlag) + : clientOutput !== undefined + ? resolvePath(configDir, clientOutput) + : resolvePath(configDir, fileNameFor(name)); + + if (!outputPath.endsWith('.ts')) { + throw new HandledError( + `\n❌ output must point at a TypeScript file (ending in .ts).\n Got: ${outputPath}\n` + ); + } + if (seenOutputs.has(outputPath)) { + throw new HandledError( + `\n❌ Two APIs write to the same path: ${outputPath}.\n Give each api a distinct \`clientOutput\`.\n` + ); + } + seenOutputs.add(outputPath); + if (clientConfig.serverUrl !== undefined && !isValidServerUrl(clientConfig.serverUrl)) { + throw new HandledError( + `\n❌ serverUrl must be an absolute URL (https://api.example.com) or a root-relative path (/v1) — set via --server-url or the \`client\` block in redocly.yaml.\n Got: ${clientConfig.serverUrl}\n` + ); + } + + try { + logger.info(gray(`\n Generating client for ${name}... \n`)); + const result = await toolkit.generateClient({ + ...clientConfig, + api: path, + output: outputPath, + config: aliasConfig, + configDir, + }); + // The emitted module decides what composes: `cli` reaches a run as a built-in + // name, a path to an ejected copy, or another generator's prerequisite. + const cliModule = result.files.find((file) => file.path.endsWith('.cli.ts')); + if (cliModule !== undefined) { + const importExt = clientConfig.importExt ?? 'js'; + composable.push({ + alias: name, + cliPath: cliModule.path.replace(/\.ts$/, importExt === 'ts' ? '.ts' : '.js'), + }); } - seenOutputs.add(outputPath); - if (clientConfig.serverUrl !== undefined && !isValidServerUrl(clientConfig.serverUrl)) { - throw new HandledError( - `\n❌ serverUrl must be an absolute URL (https://api.example.com) or a root-relative path (/v1) — set via --server-url or the \`client\` block in redocly.yaml.\n Got: ${clientConfig.serverUrl}\n` - ); + // Sibling modules (`.cli.ts`, `.zod.ts`, …) count too: the composed entry is + // written after the per-api runs and must not land on any of them. + for (const file of result.files) { + seenOutputs.add(file.path); } + const fileCount = `${result.files.length} ${pluralize('file', result.files.length)}`; + const summary = `Client successfully generated: ${fileCount} (${ + result.bytes + } bytes) at ${yellow(result.outputPath)}.`; + logger.info('\n' + blue(summary) + '\n'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + generateClientTelemetry.generate_client_error_category = categorizeGenerateClientError(message); + throw new HandledError(`\n❌ Failed to generate client for ${name}.\n ${message}\n`); + } +} - try { - logger.info(gray(`\n Generating TypeScript client for ${name}... \n`)); - const result = await generateClient({ - ...clientConfig, - api: path, - output: outputPath, - config: aliasConfig, - configDir, - }); - const fileCount = `${result.files.length} ${pluralize('file', result.files.length)}`; - const summary = `TypeScript client successfully generated: ${fileCount} (${ - result.bytes - } bytes) at ${yellow(result.outputPath)}.`; - logger.info('\n' + blue(summary) + '\n'); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new HandledError( - `\n❌ Failed to generate TypeScript client for ${name}.\n ${message}\n` - ); +/** The composed entry: one binary over every api that emitted a cli module, each behind + * its alias as a namespace. */ +async function writeComposedCliEntry( + cliOutput: string, + { configDir, seenOutputs, composable }: GenerationRun +): Promise { + const { renderComposedCliEntry } = await import('@redocly/client-generator/generate'); + const entryPath = resolvePath(configDir, cliOutput); + if (!entryPath.endsWith('.ts')) { + throw new HandledError( + `\n❌ client.cliOutput must point at a TypeScript file (ending in .ts).\n Got: ${entryPath}\n` + ); + } + if (seenOutputs.has(entryPath)) { + throw new HandledError( + `\n❌ client.cliOutput resolves to a file this run generated: ${entryPath}.\n Give the composed entry its own path.\n` + ); + } + const stem = basename(entryPath, extname(entryPath)); + const content = renderComposedCliEntry( + composable.map(({ alias, cliPath }) => ({ + alias, + modulePath: `./${relative(dirname(entryPath), cliPath).split('\\').join('/')}`, + })), + stem + ); + await mkdir(dirname(entryPath), { recursive: true }); + await writeFile(entryPath, content, 'utf-8'); + generateClientTelemetry.generate_client_composed_apis_count = composable.length; + logger.info( + '\n' + + blue( + `Composed CLI written to ${yellow(relative(process.cwd(), entryPath))} — ${composable + .map(({ alias }) => alias) + .join(', ')} behind one \`${stem}\` binary.` + ) + + '\n' + ); +} + +/** A custom generator shared by several apis counts once, like the built-in names. */ +const seenCustomEntries = new Set(); + +/** Telemetry: allowlisted built-in names, custom count, and OUR helper names a + * path generator imports — never user code, paths, or names. */ +export function collectGeneratorUsage( + entries: string[], + knownHelpers: readonly string[], + configDir: string +): void { + const builtins = new Set(generateClientTelemetry.generate_client_builtin_generators ?? []); + const toolkitImports = new Set(generateClientTelemetry.generate_client_toolkit_imports ?? []); + const ejected = new Set(generateClientTelemetry.generate_client_ejected_generators ?? []); + let customCount = generateClientTelemetry.generate_client_custom_generators_count ?? 0; + for (const entry of entries) { + if (BUILTIN_GENERATOR_NAMES.has(entry)) { + builtins.add(entry); + continue; + } + if (seenCustomEntries.has(entry)) continue; + seenCustomEntries.add(entry); + customCount++; + if (entry.startsWith('.') || isAbsolute(entry)) { + try { + // Relative entries resolve against the config's directory, like the pipeline does. + const source = readFileSync(resolvePath(configDir, entry), 'utf-8'); + for (const helper of collectToolkitImports(source, knownHelpers)) { + toolkitImports.add(helper); + } + const provenance = parseEjectedProvenance(source); + if (provenance) ejected.add(`${provenance.name}@${provenance.version}`); + } catch { + // Unreadable path: generation fails later with its own error; nothing to record. + } } } + generateClientTelemetry.generate_client_builtin_generators = [...builtins]; + generateClientTelemetry.generate_client_custom_generators_count = customCount; + generateClientTelemetry.generate_client_toolkit_imports = [...toolkitImports]; + generateClientTelemetry.generate_client_ejected_generators = [...ejected]; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 894cb5d568..f2685b9349 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -18,6 +18,11 @@ import { handleBundle } from './commands/bundle.js'; import type { ReportFormat } from './commands/drift/engine/reporter.js'; import { type DriftArgv } from './commands/drift/index.js'; import type { FindingSeverity, MatchMode, TrafficFormat } from './commands/drift/types/index.js'; +import { + EJECTABLE, + handleEjectGenerator, + type EjectGeneratorCommandArgv, +} from './commands/eject-generator.js'; import { handleEject, type EjectArgv } from './commands/eject.js'; import { handleGenerateArazzo, @@ -895,6 +900,17 @@ yargs(hideBin(process.argv)) choices: ['inline', 'package'] as const, requiresArg: true, }, + docs: { + description: + 'Also write reference documentation for what this run generates: one Markdown page per selected generator that documents itself (the CLI, and each SDK).', + type: 'boolean', + }, + 'go-package': { + description: + "Package clause of the `go` generator's output (a valid Go package name). Defaults to `client`.", + type: 'string', + requiresArg: true, + }, 'import-ext': { describe: "Extension in generated relative imports: 'js' (default) suits tsc and bundlers; 'ts' suits runtimes that resolve specifiers literally, like Node's built-in type stripping (node client.ts).", @@ -939,7 +955,7 @@ yargs(hideBin(process.argv)) }, generator: { describe: - 'Generator to run; repeat the flag to run several (default: sdk). A built-in name (sdk, zod, tanstack-query, swr, transformers, mock) or a custom-generator path/package specifier. Example: --generator sdk --generator zod', + 'Generator to run; repeat the flag to run several (default: typescript). Built-in: typescript, zod, tanstack-query, tanstack-query-vue, tanstack-query-svelte, tanstack-query-solid, swr, mock, transformers, cli, python, go, php — or a path/package specifier for a custom generator. What each one emits is in the "Use the generated client" guide. Example: --generator typescript --generator zod', type: 'string', array: true, requiresArg: true, @@ -955,6 +971,38 @@ yargs(hideBin(process.argv)) commandWrapper(handleGenerateClient)(argv as Arguments); } ) + .command( + 'eject-generator [generator]', + 'Vendor a built-in client generator into your repo as an editable file [experimental].', + (yargs) => + yargs + .positional('generator', { + describe: `Built-in generator to eject (${[...EJECTABLE].join(', ')}).`, + type: 'string', + }) + .options({ + config: { description: 'Path to the config file.', type: 'string' }, + dir: { + describe: 'Directory to eject into (default: ./generators).', + type: 'string', + requiresArg: true, + }, + force: { + describe: 'Overwrite an existing ejected file (discards local edits).', + type: 'boolean', + default: false, + }, + update: { + describe: + 'Three-way merge a newer generator version into your customized copy (pristine × new × yours).', + type: 'boolean', + default: false, + }, + }), + async (argv) => { + commandWrapper(handleEjectGenerator)(argv as Arguments); + } + ) .command( 'generate-spec ', 'Infer an OpenAPI description from recorded HTTP traffic, optionally refined with AI [experimental].', diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 14b2a0f2da..ce487055af 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -4,6 +4,7 @@ import type { LoginArgv, LogoutArgv } from './commands/auth.js'; import type { BuildDocsArgv } from './commands/build-docs/types.js'; import type { BundleArgv } from './commands/bundle.js'; import type { DriftArgv } from './commands/drift/index.js'; +import type { EjectGeneratorCommandArgv } from './commands/eject-generator.js'; import type { EjectArgv } from './commands/eject.js'; import type { GenerateArazzoCommandArgv } from './commands/generate-arazzo.js'; import type { JoinArgv } from './commands/join/types.js'; @@ -46,7 +47,8 @@ export type CommandArgv = | RespectArgv | DriftArgv | ProxyArgv - | GenerateArazzoCommandArgv; + | GenerateArazzoCommandArgv + | EjectGeneratorCommandArgv; export type VerifyConfigOptions = { config?: string; diff --git a/packages/cli/src/utils/__tests__/client-generator-telemetry.test.ts b/packages/cli/src/utils/__tests__/client-generator-telemetry.test.ts new file mode 100644 index 0000000000..07342351ac --- /dev/null +++ b/packages/cli/src/utils/__tests__/client-generator-telemetry.test.ts @@ -0,0 +1,115 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { BUILTIN_META } from '../../../../client-generator/src/generators/meta.js'; +import { EJECTABLE, FRAMEWORK_VARIANTS } from '../../commands/eject-generator.js'; +import { collectGeneratorUsage } from '../../commands/generate-client.js'; +import { + BUILTIN_GENERATOR_NAMES, + categorizeGenerateClientError, + collectToolkitImports, + generateClientTelemetry, + parseEjectedProvenance, +} from '../client-generator-telemetry.js'; + +describe('collectToolkitImports', () => { + it('returns only OUR helper names from client-generator imports — never user identifiers', () => { + const source = [ + "import { flattenAllOf, Printer, mySecretHelper } from '@redocly/client-generator';", + "import { printStatements } from '@redocly/client-generator/generate';", + "import { internalThing } from './our-private-module.js';", + ].join('\n'); + expect(collectToolkitImports(source, ['flattenAllOf', 'Printer', 'printStatements'])).toEqual([ + 'flattenAllOf', + 'Printer', + 'printStatements', + ]); + }); + + it('handles aliased and type-only named imports', () => { + const source = + "import { type flattenAllOf, Printer as Writer } from '@redocly/client-generator';"; + expect(collectToolkitImports(source, ['flattenAllOf', 'Printer'])).toEqual([ + 'flattenAllOf', + 'Printer', + ]); + }); +}); + +describe('categorizeGenerateClientError', () => { + it('maps known failure shapes to coarse categories', () => { + expect(categorizeGenerateClientError('Invalid pagination configuration:…')).toBe('pagination'); + expect(categorizeGenerateClientError('Could not load generator "./x.mjs": …')).toBe( + 'generator-load' + ); + expect(categorizeGenerateClientError('Unknown generator: foo')).toBe('not-supported'); + expect( + categorizeGenerateClientError('The "swr" generator does not support --error-mode "result"') + ).toBe('not-supported'); + expect(categorizeGenerateClientError('boom')).toBe('other'); + expect(categorizeGenerateClientError('Generator "php" failed: something broke')).toBe( + 'generator-run' + ); + }); +}); + +describe('BUILTIN_GENERATOR_NAMES', () => { + // Every built-in ships as a vendorable asset, so EJECTABLE plus the framework variants + // is the full set. A built-in missing here is counted as a custom generator and its + // ejected provenance header is ignored. + it('covers every built-in', () => { + const builtins = [...EJECTABLE, ...FRAMEWORK_VARIANTS.keys()].sort(); + expect([...BUILTIN_GENERATOR_NAMES].sort()).toEqual(builtins); + }); + + it('matches the generator registry, so a new built-in cannot skip eject or telemetry', () => { + expect([...BUILTIN_GENERATOR_NAMES].sort()).toEqual(Object.keys(BUILTIN_META).sort()); + }); +}); + +describe('collectGeneratorUsage', () => { + it('resolves config-relative paths against the config dir and counts a shared custom once', () => { + for (const key of Object.keys(generateClientTelemetry)) { + delete generateClientTelemetry[key as keyof typeof generateClientTelemetry]; + } + const configDir = mkdtempSync(join(tmpdir(), 'generate-client-telemetry-')); + try { + mkdirSync(join(configDir, 'generators')); + writeFileSync( + join(configDir, 'generators/php.mjs'), + '// Ejected from @redocly/client-generator@0.3.0 — the built-in "php" generator.\n' + + "import { Printer } from '@redocly/client-generator';\n", + 'utf-8' + ); + // Two apis, the same entries — the cwd is elsewhere, only configDir resolves them. + collectGeneratorUsage(['typescript', './generators/php.mjs'], ['Printer'], configDir); + collectGeneratorUsage(['typescript', './generators/php.mjs'], ['Printer'], configDir); + expect(generateClientTelemetry).toEqual({ + generate_client_builtin_generators: ['typescript'], + generate_client_custom_generators_count: 1, + generate_client_toolkit_imports: ['Printer'], + generate_client_ejected_generators: ['php@0.3.0'], + }); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }); +}); + +describe('parseEjectedProvenance', () => { + it('reads OUR provenance header — an allowlisted name and version, nothing user-authored', () => { + const source = + '// Ejected from @redocly/client-generator@0.2.0 — the built-in "php" generator.\n// rest…'; + expect(parseEjectedProvenance(source)).toEqual({ name: 'php', version: '0.2.0' }); + }); + + it('returns undefined for non-ejected files and non-allowlisted names', () => { + expect(parseEjectedProvenance('export default { name: "mine", run() {} }')).toBeUndefined(); + expect( + parseEjectedProvenance( + '// Ejected from @redocly/client-generator@0.2.0 — the built-in "evil()" generator.' + ) + ).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/utils/client-generator-telemetry.ts b/packages/cli/src/utils/client-generator-telemetry.ts new file mode 100644 index 0000000000..beb0b56d7c --- /dev/null +++ b/packages/cli/src/utils/client-generator-telemetry.ts @@ -0,0 +1,103 @@ +// generate-client telemetry collectors. Two hard rules (documented on the +// telemetry docs page): user code contents, paths, and generator names are never +// transmitted — only the names of OUR exported helpers a custom generator imports, +// counts, and coarse error categories. Everything rides the REDOCLY_TELEMETRY opt-out. + +export type GenerateClientTelemetry = { + generate_client_builtin_generators?: string[]; + generate_client_custom_generators_count?: number; + generate_client_toolkit_imports?: string[]; + generate_client_error_category?: string; + /** Ejected built-ins in the run, as `@` (from OUR provenance header). */ + generate_client_ejected_generators?: string[]; + /** How many apis the composed CLI entry (`client.cliOutput`) spanned, when one was written. */ + generate_client_composed_apis_count?: number; +}; + +/** Populated by handleGenerateClient; spread into the telemetry payload by the wrapper. */ +export const generateClientTelemetry: GenerateClientTelemetry = {}; + +/** Allowlist for the builtin-usage event — anything not here is counted, never named. */ +export const BUILTIN_GENERATOR_NAMES = new Set([ + 'typescript', + 'zod', + 'tanstack-query', + 'tanstack-query-vue', + 'tanstack-query-svelte', + 'tanstack-query-solid', + 'swr', + 'transformers', + 'mock', + 'cli', + 'python', + 'go', + 'php', +]); + +const IMPORT_RE = + /import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]@redocly\/client-generator(?:\/generate)?['"]/g; + +/** + * Names of OUR exports found in an import from '@redocly/client-generator[/generate]'. + * Approximate by design: a regex over source text can match a commented-out import, and + * that's fine — this feeds a usage histogram of allowlisted helper names, never anything + * that gates generation. Parsing properly would put `typescript` back into the CLI path. + */ +export function collectToolkitImports(source: string, knownHelpers: readonly string[]): string[] { + const known = new Set(knownHelpers); + const found = new Set(); + for (const match of source.matchAll(IMPORT_RE)) { + for (const raw of match[1].split(',')) { + const name = raw + .trim() + .replace(/^type\s+/, '') + .split(/\s+as\s+/)[0] + .trim(); + if (known.has(name)) found.add(name); + } + } + return [...found]; +} + +const PROVENANCE_RE = + /^\/\/ Ejected from @redocly\/client-generator@([\w.-]+) — the built-in "([a-z-]+)" generator\./; + +/** + * The ``/`` from OUR eject provenance header, when the file carries one + * and the name is allowlisted — an ejected generator's origin, never user-authored text. + */ +export function parseEjectedProvenance( + source: string +): { name: string; version: string } | undefined { + const match = source.match(PROVENANCE_RE); + if (!match || !BUILTIN_GENERATOR_NAMES.has(match[2])) return undefined; + return { name: match[2], version: match[1] }; +} + +/** Coarse category from an error message — never the message itself. */ +export function categorizeGenerateClientError(message: string): string { + if (message.includes('Invalid pagination configuration')) return 'pagination'; + if (message.includes('Could not load generator')) return 'generator-load'; + if (/^Generator "[^"]+" failed:/.test(message)) return 'generator-run'; + if (message.includes('Unknown generator') || message.includes('does not support')) { + return 'not-supported'; + } + return 'other'; +} + +export type EjectGeneratorTelemetry = { + /** 'eject' | 'update' | 'guidance'. */ + eject_generator_action?: string; + /** Allowlisted built-in name only; unknown names stay unnamed. */ + eject_generator_name?: string; + /** Coarse outcome: success | conflicts | already-exists | missing-target | missing-base | merge-tool-missing | merge-failed | unknown-generator | unexpected-error. */ + eject_generator_outcome?: string; + eject_generator_conflicts?: number; + /** `--update` only: the toolkit version the file was ejected from — OUR version string, semver-checked. */ + eject_generator_from_version?: string; + /** `--update` only: the installed toolkit version the merge targets. */ + eject_generator_to_version?: string; +}; + +/** Populated by the eject-generator handler; spread into the telemetry payload by the wrapper. */ +export const ejectGeneratorTelemetry: EjectGeneratorTelemetry = {}; diff --git a/packages/cli/src/utils/telemetry.ts b/packages/cli/src/utils/telemetry.ts index 277de71843..d1702165a6 100644 --- a/packages/cli/src/utils/telemetry.ts +++ b/packages/cli/src/utils/telemetry.ts @@ -21,6 +21,10 @@ import type { Arguments } from 'yargs'; import type { CriterionObject } from '../../../core/src/typings/arazzo.js'; import { getReuniteUrl } from '../reunite/api/index.js'; import type { CommandArgv } from '../types.js'; +import type { + EjectGeneratorTelemetry, + GenerateClientTelemetry, +} from './client-generator-telemetry.js'; import { ANONYMOUS_ID_CACHE_FILE } from './constants.js'; import type { ExitCode } from './miscellaneous.js'; import { respondWithinMs } from './network-check.js'; @@ -46,6 +50,8 @@ export async function sendTelemetry({ lint_rules_with_errors, lint_rules_with_warnings, lint_rules_with_ignored_problems, + generate_client, + eject_generator, }: { config: Config | undefined; argv: Arguments | undefined; @@ -60,6 +66,8 @@ export async function sendTelemetry({ lint_rules_with_errors: string[] | undefined; lint_rules_with_warnings: string[] | undefined; lint_rules_with_ignored_problems: string[] | undefined; + generate_client?: GenerateClientTelemetry; + eject_generator?: EjectGeneratorTelemetry; }): Promise { try { if (!argv) { @@ -128,6 +136,31 @@ export async function sendTelemetry({ lint_rules_with_ignored_problems: lint_rules_with_ignored_problems?.length ? JSON.stringify(lint_rules_with_ignored_problems) : undefined, + // generate-client usage (names of OUR generators/helpers only — never user + // code, paths, or names; see utils/generate-client-telemetry.ts). + generate_client_builtin_generators: generate_client?.generate_client_builtin_generators + ?.length + ? JSON.stringify(generate_client.generate_client_builtin_generators) + : undefined, + generate_client_custom_generators_count: + generate_client?.generate_client_custom_generators_count, + generate_client_toolkit_imports: generate_client?.generate_client_toolkit_imports?.length + ? JSON.stringify(generate_client.generate_client_toolkit_imports) + : undefined, + generate_client_error_category: generate_client?.generate_client_error_category, + generate_client_ejected_generators: generate_client?.generate_client_ejected_generators + ?.length + ? JSON.stringify(generate_client.generate_client_ejected_generators) + : undefined, + generate_client_composed_apis_count: generate_client?.generate_client_composed_apis_count, + // eject-generator usage (action, allowlisted name, coarse outcome — never + // user paths or user-chosen names). + eject_generator_action: eject_generator?.eject_generator_action, + eject_generator_name: eject_generator?.eject_generator_name, + eject_generator_outcome: eject_generator?.eject_generator_outcome, + eject_generator_conflicts: eject_generator?.eject_generator_conflicts, + eject_generator_from_version: eject_generator?.eject_generator_from_version, + eject_generator_to_version: eject_generator?.eject_generator_to_version, }, ]; diff --git a/packages/cli/src/wrapper.ts b/packages/cli/src/wrapper.ts index c1afeca448..9f94d8dce2 100644 --- a/packages/cli/src/wrapper.ts +++ b/packages/cli/src/wrapper.ts @@ -15,6 +15,10 @@ import { import type { Arguments } from 'yargs'; import type { CommandArgv } from './types.js'; +import { + ejectGeneratorTelemetry, + generateClientTelemetry, +} from './utils/client-generator-telemetry.js'; import { AbortFlowError, exitWithError } from './utils/error.js'; import { loadConfigAndHandleErrors, type ExitCode } from './utils/miscellaneous.js'; import { version } from './utils/package.js'; @@ -91,18 +95,14 @@ export function commandWrapper( const lintRulesWithWarnings = new Set(); const lintRulesWithIgnoredProblems = new Set(); const collectResults: CollectResults = (results) => { - try { - for (const problem of results) { - if (problem.ignored) { - lintRulesWithIgnoredProblems.add(problem.ruleId); - } else if (problem.severity === 'error') { - lintRulesWithErrors.add(problem.ruleId); - } else if (problem.severity === 'warn') { - lintRulesWithWarnings.add(problem.ruleId); - } + for (const problem of results) { + if (problem.ignored) { + lintRulesWithIgnoredProblems.add(problem.ruleId); + } else if (problem.severity === 'error') { + lintRulesWithErrors.add(problem.ruleId); + } else if (problem.severity === 'warn') { + lintRulesWithWarnings.add(problem.ruleId); } - } catch (err) { - // Do nothing. } }; @@ -146,6 +146,8 @@ export function commandWrapper( lint_rules_with_errors: [...lintRulesWithErrors], lint_rules_with_warnings: [...lintRulesWithWarnings], lint_rules_with_ignored_problems: [...lintRulesWithIgnoredProblems], + generate_client: generateClientTelemetry, + eject_generator: ejectGeneratorTelemetry, }); } process.once('beforeExit', () => { diff --git a/packages/client-generator/.gitignore b/packages/client-generator/.gitignore new file mode 100644 index 0000000000..bb28f23849 --- /dev/null +++ b/packages/client-generator/.gitignore @@ -0,0 +1 @@ +eject-assets/generators/ diff --git a/packages/client-generator/ARCHITECTURE.md b/packages/client-generator/ARCHITECTURE.md index 413461dc3a..df615d42fa 100644 --- a/packages/client-generator/ARCHITECTURE.md +++ b/packages/client-generator/ARCHITECTURE.md @@ -47,16 +47,27 @@ flowchart LR ## Module map -| Area | Files | Owns | Depth | -| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | -| Entry | `index.ts`, `types.ts`, `config.ts`, `config-file.ts`, `plugin.ts` | `generateClient` orchestration; public option/result types; config loading; the experimental `@redocly/client-generator` entry (`defineGenerator` + IR types + codegen toolkit) | thin orchestrator | -| Load | `loader.ts` | bundle + `$ref` resolution, preserving internal refs | deep (hides `openapi-core`) | -| IR | `intermediate-representation/build.ts`, `intermediate-representation/model.ts`, `intermediate-representation/refs.ts`, `intermediate-representation/normalize-swagger2.ts`, `intermediate-representation/sanitize-identifiers.ts` | OpenAPI → IR; the IR type model; ref collection; Swagger 2.0 → 3.x normalization; coerce document-derived names to safe unique identifiers (security boundary) | deep (`buildApiModel` + `normalizeSwagger2` each one interface over a whole walk) | -| Writers | `writers/index.ts`, `single-file-writer.ts`, `split-writer.ts`, `util.ts`, `types.ts` | file layout per output mode (`single`, `split`) over the shared wiring emitter | thin adapters at the `getWriter` seam | -| Generators | `generators/index.ts` (registry + `validateGenerators`), `resolve.ts` (built-in / inline / specifier resolution), `types.ts`, `sdk.ts`, `zod.ts`, `tanstack-query.ts`, `swr.ts`, `transformers.ts`, `mock.ts` | the generator registry seam: each descriptor declares its requires/errorModes/dateTypes/runtimes and produces `GeneratedFile[]` by calling an emitter; `resolve.ts` turns a selection (built-in names, inline `customGenerators`, or plugin import specifiers) into a name→descriptor registry | thin adapters at the `getGenerator` seam ([ADR-0004](./docs/adr/0004-registry-seams.md), [ADR-0012](./docs/adr/0012-plugin-api.md)) | -| Runtime | `runtime/types.ts`, `errors.ts`, `url.ts`, `parse.ts`, `retry.ts`, `multipart.ts`, `auth.ts`, `setup.ts`, `send.ts`, `sse.ts`, `create-client.ts`, `index.ts` (the package barrel) | the client engine as real, unit-testable TypeScript modules: `createClient` builds a typed instance client over operation descriptors, dispatching optional behaviors (multipart, auth, SSE) through a capability seam; the barrel wires the full capability set for package-mode consumers | deep (`createClient` is one interface over the whole engine) | -| Emitters | sdk wiring: `emitters/package-client.ts` (the shared wiring emitter), `descriptor.ts` (OPERATIONS + `Ops`), `inline-runtime.ts` (the inline assembler) + generated `runtime-sources.ts`, `client.ts` (options + banners), `types.ts`, `type-guards.ts`, `auth.ts` (setter names), `operations.ts` (+ `operation-aliases.ts`, `operation-types.ts`), `sse.ts`, `setup-bake.ts`; satellite: `zod.ts`, `transformers.ts`, `tanstack-query.ts`, `swr.ts` (+ shared `wrapper-support.ts`), `mock.ts`/`faker.ts`/`sample.ts`; foundation `ts.ts`; shared `operation-signature.ts`; private `support.ts`, `jsdoc.ts`, `identifier.ts` | IR → TypeScript AST (`ts.factory` nodes, printed via `ts.ts`); `descriptor.ts` emits the pure-data operation descriptors and the `Ops` type; `sse.ts` is the SSE detection seam; `operation-signature.ts` is the single source of an operation’s calling convention; `wrapper-support.ts` is the shared eligibility/param model for `swr` + `tanstack-query` | each emitter is deep (one entry point builds nodes over hidden bulk); `package-client.ts` assembles the per-file content and prints once | -| Errors | `errors.ts` | `NotSupportedError` | trivial | +**Where a renderer lives.** `generators//index.ts` is the entry: it reads the +options, decides the output paths, and calls a renderer. The renderer itself lives in +`emitters/`, because that layer already holds the shared pieces every renderer composes +with — `operation-signature.ts` for the calling convention, `ts-type.ts` for schema +types, `pagination.ts`, `sse.ts`. `emitters/cli.ts` is there for that reason: `cli` +renders from it, its `docs` hook renders the reference page from the same command table, and the +package entry exports its composed-entry renderer. + +The three language SDKs are the exception. `python`, `go`, and `php` compose with +nothing in `emitters/` — each is one self-contained file in its generator folder, which +is also what lets `eject-generator` hand a user its source instead of a bundle. + +| Area | Files | Owns | Depth | +| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Entry | `index.ts`, `types.ts`, `config.ts`, `config-file.ts`, `plugin.ts` | `generateClient` orchestration; public option/result types; config loading; the experimental `@redocly/client-generator` entry (`defineGenerator` + IR types + codegen toolkit) | thin orchestrator | +| Load | `loader.ts` | bundle + `$ref` resolution, preserving internal refs | deep (hides `openapi-core`) | +| IR | `intermediate-representation/build.ts`, `intermediate-representation/model.ts`, `intermediate-representation/refs.ts`, `intermediate-representation/normalize-swagger2.ts`, `intermediate-representation/sanitize-identifiers.ts` | OpenAPI → IR; the IR type model; ref collection; Swagger 2.0 → 3.x normalization; coerce document-derived names to safe unique identifiers (security boundary) | deep (`buildApiModel` + `normalizeSwagger2` each one interface over a whole walk) | +| Generators | `generators/index.ts` (registry + `validateGenerators`), `meta.ts` (lazy-load metadata + selection validation), `resolve.ts` (built-in / inline / specifier resolution), `contract.ts` (`GENERATOR_CONTRACT`), `types.ts`, and ONE FOLDER PER GENERATOR — `sdk/`, `zod/`, `tanstack-query/`, `swr/`, `transformers/`, `mock/`, `cli/`, `python/`, `go/`, `php/` — each holding `index.ts` plus its own `AGENTS.md` design skill | the generator registry seam: each descriptor declares its requires/errorModes/dateTypes/runtimes (plus `notApplicable` options it can't honor) and produces `GeneratedFile[]`; `resolve.ts` turns a selection into a name→descriptor registry. The three language generators are self-contained single files (hence ejectable); the TypeScript ones are thin entries over the shared emitters | thin adapters at the `getGenerator` seam ([ADR-0004](./docs/adr/0004-registry-seams.md), [ADR-0012](./docs/adr/0012-plugin-api.md)) | +| Runtime | `runtime/types.ts`, `errors.ts`, `url.ts`, `parse.ts`, `retry.ts`, `multipart.ts`, `auth.ts`, `setup.ts`, `send.ts`, `sse.ts`, `create-client.ts`, `index.ts` (the package barrel) | the client engine as real, unit-testable TypeScript modules: `createClient` builds a typed instance client over operation descriptors, dispatching optional behaviors (multipart, auth, SSE) through a capability seam; the barrel wires the full capability set for package-mode consumers | deep (`createClient` is one interface over the whole engine) | +| Emitters | sdk: `emitters/client-assembly.ts` (assembly + output modes), `render-client.ts` (Ops, `*` aliases, flat sugar), `descriptor.ts`, `ts-type.ts`/`ts-literal.ts`, `type-guards.ts`, `sse.ts`, `pagination.ts`, `response-headers.ts`, `inline-runtime.ts` + generated `runtime-sources.ts`, `setup-bake.ts`; satellite: `zod.ts`, `transformers.ts`, `tanstack-query.ts`, `swr.ts` (+ shared `wrapper-support.ts`), `mock.ts`/`mock-value.ts`/`faker.ts`/`sample.ts`, `cli.ts`; shared `operation-signature.ts`; private `support.ts`, `jsdoc.ts`, `identifier.ts`; `ts.ts` (the last `typescript` dependency — `--setup` baking only) | IR → TypeScript SOURCE TEXT (templates over `Printer`-style string building, not an AST — see the ADR on the AST removal); `descriptor.ts` emits the pure-data descriptors and the `Ops` type; `sse.ts` is the SSE detection seam; `operation-signature.ts` is the single source of an operation's calling convention; `wrapper-support.ts` is the shared eligibility/param model for `swr` + `tanstack-query` | each emitter is deep (one entry point over hidden bulk); `client-assembly.ts` assembles per-file content | +| Errors | `errors.ts` | `NotSupportedError` | trivial | The IR (`intermediate-representation/model.ts`) is a **pure type model** — no runtime code. It is the contract between the builder and the emitters ([ADR-0003](./docs/adr/0003-spec-agnostic-ir.md)). @@ -66,7 +77,7 @@ builder and the emitters ([ADR-0003](./docs/adr/0003-spec-agnostic-ir.md)). Places where behavior varies without editing in place: - **The `getGenerator` seam** — a generator is `(input) => GeneratedFile[]` (`generators/types.ts`). - `generateClient` resolves the configured selection (default `['sdk']`) via `resolveGenerators` (`generators/resolve.ts`) into a name→descriptor registry, then runs them through `collectGeneratedFiles` and merges their files (duplicate output paths throw). + `generateClient` resolves the configured selection (default `['typescript']`) via `resolveGenerators` (`generators/resolve.ts`) into a name→descriptor registry, then runs them through `collectGeneratedFiles` and merges their files (duplicate output paths throw). A selection entry is a built-in name, the `name` of an inline `customGenerators` entry, or a **plugin import specifier** (path or package, dynamically imported and validated). This is the public, **experimental** extension point — authored with `defineGenerator` from `@redocly/client-generator`, which also re-exports the IR types and the codegen toolkit. Where new capabilities (zod, framework hooks) plug in. @@ -105,7 +116,7 @@ Three orthogonal knobs combine freely: Plus **error mode** (`--error-mode`: `throw` · `result`), **date type** (`--date-type`: `string` · `Date`), and the `--server-url` / `--setup` modifiers. Every client exports **both call styles** — the instance and the free functions; args style only shapes the free-function sugar. -Orthogonally, **`--generator`** selects which generators run (default `sdk`; plus `zod`, `tanstack-query` (React; `-vue`/`-svelte`/`-solid` variants), `swr`, `transformers`, `mock`, and custom plugins), with per-generator knobs: `--mock-data` (`static` · `faker`) / `--mock-seed` (for `mock`). +Orthogonally, **`--generator`** selects which generators run (default `typescript`; plus `zod`, `tanstack-query` (React; `-vue`/`-svelte`/`-solid` variants), `swr`, `transformers`, `mock`, and custom plugins), with per-generator knobs: `--mock-data` (`static` · `faker`) / `--mock-seed` (for `mock`). ## Test architeture @@ -122,11 +133,11 @@ Compile (`npm run compile`) before running tests — they run against built outp - **A new output mode** — add the literal to `OutputMode` (`writers/types.ts`), write a `Writer` over the shared wiring emitter, and register it in the `WRITERS` map (`writers/index.ts`). Wire the CLI choice in the `generate-client` command. -- **A new schema kind** — add the variant to `SchemaModel` (`intermediate-representation/model.ts`), produce it in `intermediate-representation/build.ts`, and build its `ts.TypeNode` in `schemaToTypeNode` (`emitters/types.ts`). +- **A new schema kind** — add the variant to `SchemaModel` (`intermediate-representation/model.ts`), produce it in `intermediate-representation/build.ts`, render it in `tsType` (`emitters/ts-type.ts`), and cover it in each language generator's type mapper (`generators//index.ts`). - **A new runtime capability** — add a module under `src/runtime/`, thread it through the `Capabilities` seam (`runtime/create-client.ts`), wire it in the barrel (`runtime/index.ts`), list it in `scripts/generate-runtime-sources.mjs`, and teach `emitters/inline-runtime.ts` when to embed it (a new `InlineRuntimeNeeds` flag). Run `npm run compile` to regenerate the `runtime-sources.ts` snapshot. - **A new wrapper generator** (a framework adapter that forwards to the sdk functions) — reuse `emitters/wrapper-support.ts` for operation eligibility (SSE / `Variables`-collision skips) and the `vars`/`init` parameter shape, and derive the forwarding call's argument order and `Variables` naming from `operationSignature` (`emitters/operation-signature.ts`), the same source the sdk's parameter list uses, so the wrappers cannot drift. - Declare its compatibility contract (`requires`/`errorModes`/`dateTypes`/`runtimes`) in the generator registry (`generators/index.ts`). + Declare its compatibility contract (`requires`/`errorModes`/`dateTypes`/`runtimes`, plus `notApplicable` for options it cannot honor) in `generators/meta.ts`, and give it a folder with an `AGENTS.md` design skill (a guard test enforces this). See [ADR-0011](./docs/adr/0011-wrapper-generators.md). - **A new mock data source** — the `mock` generator's data comes from `emitters/sample.ts` (baked literals) or `emitters/faker.ts` (faker calls), selected by `--mock-data`; both walk the IR with the same cycle semantics. See [ADR-0010](./docs/adr/0010-mock-data-baked-vs-faker.md). diff --git a/packages/client-generator/CONTEXT.md b/packages/client-generator/CONTEXT.md index 908687c0ea..daf97b1f14 100644 --- a/packages/client-generator/CONTEXT.md +++ b/packages/client-generator/CONTEXT.md @@ -39,10 +39,10 @@ _Avoid_: streamSchema, eventSchema (in code identifiers — `itemSchema` mirrors ### Emission **Emitter**: -Builds a TypeScript **AST** (`ts.factory` nodes) from the IR. +Renders TypeScript source TEXT from the IR, through `Printer`. Lives in `emitters/`. -Each emitter is deep — one narrow entry point over hidden node-building bulk — and owns a single concern: `types.ts` (`typesStatements`/`schemaToTypeNode`), `type-guards.ts` (`typeGuardStatements`), `descriptor.ts` (the `OPERATIONS` descriptor map + the `Ops` type), `operation-aliases.ts`/`operation-types.ts` (the `*` aliases and their type builders), `sse.ts` (the **SSE** detection seam: `isSseOp`/`partitionOps`/`sseEventType`/`sseDataKind`), and `inline-runtime.ts` (the **inline assembler**). -The foundation module `ts.ts` owns the shared printer and ergonomics: `printNodes` (nodes → source), `parseStatements` (parse hand-authored source into nodes), and `jsdoc` (attach a block comment). +Each emitter is deep — one narrow entry point over hidden rendering bulk — and owns a single concern: `types.ts`, `type-guards.ts`, `descriptor.ts` (the `OPERATIONS` descriptor map + the `Ops` type), `operation-aliases.ts`/`operation-types.ts` (the `*` aliases), `ts-type.ts` (`tsType`, the schema→type renderer), `sse.ts` (the **SSE** detection seam: `isSseOp`/`partitionOps`/`sseEventType`/`sseDataKind`), and `inline-runtime.ts` (the **inline assembler**). +`setup-bake.ts` is the only module that parses TypeScript (a publisher `--setup` module), which is why `typescript` is an optional peer dependency loaded lazily. `package-client.ts` is the shared _wiring_ emitter: it assembles each file's content — identical for both runtimes except the runtime block (import vs embed) — and prints **once**, exposing `emitClientSingleFile` / `emitClientSplit`. Low-level text helpers (`pascalCase`, `splitLines`, `joinSections`) stay private in `support.ts`, and the JSDoc-body builder in `jsdoc.ts` — consumed only by the deep emitters, never by writers. _Avoid_: renderer, codegen. @@ -51,17 +51,18 @@ _Avoid_: renderer, codegen. Chooses the _file layout_ from the IR and emit options, then fills each file by calling the emitter. Lives in `writers/`. One **Writer** per **output mode**, selected by `getWriter`. -A Writer is an implementation detail of the `sdk` **Generator**. +A Writer is an implementation detail of the `typescript` **Generator**. _Avoid_: formatter, builder. **Generator**: -A deep module that turns the IR into a set of files for one concern, selected by name through `getGenerator(name)` (mirrors the `getWriter(outputMode)` seam). -Lives in `generators/`. -The `sdk` generator is the typed client (it delegates to the output-mode **Writer**). +A deep module that turns the IR into a set of files for one concern, selected by name through the registry seam. +Each one lives in its OWN FOLDER under `generators/` — `index.ts` plus an `AGENTS.md` design skill that the code must match (change the skill first). +The `python`, `go`, and `php` generators are self-contained single files, which is what makes them ejectable; the TypeScript-emitting ones are thin entries over the shared emitters. +The `typescript` generator is the typed client (it delegates to the output-mode **Writer**). The `zod` generator emits a standalone `.zod.ts` **schema module** (one `export const Schema` per IR named schema) beside the client. -The `tanstack-query` generator emits a TanStack Query v5 (React) module (`.tanstack.ts`) wrapping the sdk — per query op a `QueryKey`/`Options` (`queryOptions`) factory + query key, per mutation a `Mutation` (`mutationKey`/`mutationFn`) factory (requires the `sdk` generator; the consumer installs `@tanstack/react-query`). +The `tanstack-query` generator emits a TanStack Query v5 (React) module (`.tanstack.ts`) wrapping the sdk — per query op a `QueryKey`/`Options` (`queryOptions`) factory + query key, per mutation a `Mutation` (`mutationKey`/`mutationFn`) factory (requires the `typescript` generator; the consumer installs `@tanstack/react-query`). The `transformers` generator emits a standalone `.transformers.ts` of `transform(data: ): ` functions — one per IR named schema that (recursively) carries a `date-time`/`date` field — that walk the value and rewrite wire ISO strings to `new Date(...)` in place, composing across refs (`transformPet` calls `transformOwner`); pair it with the **dateType** knob (`--date-type Date`) so the parsed value matches the type (it imports only the schema TYPES, so the client stays zero-dep). -`generateClient` runs the configured generators (default `['sdk']`, selected via `--generator sdk --generator zod`) and merges their files. +`generateClient` runs the configured generators (default `['typescript']`, selected via `--generator typescript --generator zod`) and merges their files. Custom generators are authored with `defineGenerator` and selected inline or by import specifier (the experimental **plugin** API, ADR-0012). _Avoid_: middleware (that's a runtime concept). @@ -126,7 +127,7 @@ _Avoid_: throwOnError, errorHandling, result shape (in code identifiers). **dateType**: How `format: date-time`/`date` string fields are typed: `string` (default — byte-identical to the ISO wire shape) or `Date`. Selected by `--date-type`. -Under `Date` the sdk emits `Date` for those scalar `string` schemas; the runtime conversion is opt-in and separate — pair it with the **`transformers` generator** (`--generator sdk --generator transformers`) so the parsed value matches the type. +Under `Date` the sdk emits `Date` for those scalar `string` schemas; the runtime conversion is opt-in and separate — pair it with the **`transformers` generator** (`--generator typescript --generator transformers`) so the parsed value matches the type. `int64` → `bigint` is deferred to a follow-up. _Avoid_: dateMode, parseDates (in code identifiers). diff --git a/packages/client-generator/README.md b/packages/client-generator/README.md index 7b10018271..7f878dd962 100644 --- a/packages/client-generator/README.md +++ b/packages/client-generator/README.md @@ -27,7 +27,7 @@ import { generateClient } from '@redocly/client-generator'; const result = await generateClient({ api: './openapi.yaml', // file path or URL; OpenAPI 3.0/3.1/3.2 or Swagger 2.0 output: './src/api/client.ts', - generators: ['sdk', 'zod'], + generators: ['typescript', 'zod'], }); console.log(`Wrote ${result.files.length} file(s), ${result.bytes} bytes.`); @@ -55,43 +55,32 @@ With `runtime: 'package'` the generated client also imports its whole engine fro ### Write a custom generator A custom generator reads the same API model the built-ins consume, runs in the same pass, and returns files. -Build real TypeScript with the emit toolkit from `@redocly/client-generator/generate` — the same `ts.factory` + printer the built-in generators use, so the schema→type mapping matches the sdk's exactly: +Emitters print text: `Printer` handles indentation, and `tsType` is the same schema→type renderer the built-in sdk uses, so the mapping (refs, arrays, unions, formats, parenthesization) matches the generated client exactly: ```ts // response-map-generator.ts -import { defineGenerator } from '@redocly/client-generator'; -import { printStatements, schemaToTypeNode, ts } from '@redocly/client-generator/generate'; - -const { factory } = ts; +import { defineGenerator, Printer } from '@redocly/client-generator'; +import { tsType } from '@redocly/client-generator/generate'; export default defineGenerator({ name: 'response-map', - requires: ['sdk'], + requires: ['typescript'], run({ model, outputPath }) { + const printer = new Printer(); // One `ResponseShapes` entry per operation with a JSON success body. - const members = model.services - .flatMap((service) => service.operations) - .flatMap((op) => { - const success = op.successResponses.find((r) => r.contentType.includes('json')); - if (!success) return []; - return [ - factory.createPropertySignature( - undefined, - op.name, - undefined, - schemaToTypeNode(success.schema) - ), - ]; - }); - const alias = factory.createTypeAliasDeclaration( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - 'ResponseShapes', - undefined, - factory.createTypeLiteralNode(members) + printer.block( + 'export type ResponseShapes = {', + () => { + for (const service of model.services) { + for (const op of service.operations) { + const success = op.successResponses.find((r) => r.contentType.includes('json')); + if (success) printer.line(`${op.name}: ${tsType(success.schema)};`); + } + } + }, + '};' ); - return [ - { path: outputPath.replace(/\.ts$/, '.responses.ts'), content: printStatements([alias]) }, - ]; + return [{ path: outputPath.replace(/\.ts$/, '.responses.ts'), content: printer.toString() }]; }, }); ``` @@ -150,7 +139,10 @@ Authors a custom generator (`{ name, run }` plus optional `requires`/`errorModes function defineGenerator(generator: CustomGenerator): CustomGenerator; ``` -The `@redocly/client-generator/generate` entry also exports the emit toolkit the built-ins use (`ts`, `printStatements`, `parseStatements`, `operationSignature`, `schemaToTypeNode`, `pascalCase`, …), and the package root exports the IR types, so a custom generator emits TypeScript exactly as the first-party ones do — see the [`ast-toolkit-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/ast-toolkit-generator). +The `@redocly/client-generator/generate` entry also exports the TypeScript renderers the built-ins use (`tsType`, `tsJsdoc`, `codeLiteral`, `operationSignature`, `pascalCase`, `safeIdent`). +The package root exports the IR types plus the language-neutral toolkit. +A custom generator emits TypeScript exactly as the first-party ones do. +See the [`typescript-types-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/typescript-types-generator). ### `defineClientSetup` diff --git a/packages/client-generator/docs/adr/0018-auto-pagination.md b/packages/client-generator/docs/adr/0018-auto-pagination.md index 2ce3bebb3b..9f22cd4cd6 100644 --- a/packages/client-generator/docs/adr/0018-auto-pagination.md +++ b/packages/client-generator/docs/adr/0018-auto-pagination.md @@ -16,7 +16,7 @@ Additional forces: the descriptor contract is frozen ([ADR-0017](./0017-runtime- **Pagination is declared, then statically verified against the spec — never guessed.** -1. **Config-first declaration with a verified convention.** The `pagination` option (`redocly.yaml` `client.pagination`) carries one convention rule (`style: cursor | offset | page`, the style's advance param, `nextCursor`/`items` JSON pointers, optional `limitParam`) plus `operations` per-op overrides and an `exclude` list; the `x-redocly-pagination` operation extension takes the same rule fields inline in the spec. Precedence per operation: `operations[id]` > `x-redocly-pagination` > convention, with `exclude` killing all sources. +1. **Config-first declaration with a verified convention.** The `pagination` option (`redocly.yaml` `client.pagination`) carries one convention rule (`style: cursor | offset | page`, the style's advance param, `nextCursor`/`items` JSON pointers, optional `limitParam`) plus `operations` per-op overrides and an `exclude` list; the `x-redoclyPagination` operation extension takes the same rule fields inline in the spec. Precedence per operation: `operations[id]` > `x-redoclyPagination` > convention, with `exclude` killing all sources. The convention applies only to operations it **structurally fits** — the advance param is a declared query parameter whose schema accepts what the runtime sends (string-ish for `cursor`, numeric for `offset`/`page`), and the pointers resolve over the JSON success-response schema with `items` landing on an array. A convention misfit silently skips the operation; an **explicit** rule that doesn't fit — and a malformed rule from any source — fails generation with per-operation errors aggregated into one throw. No name sniffing, no shape guessing. 2. **Item typing is static, from the IR.** `emitters/pagination.ts` resolves the `items` pointer against the success-response `SchemaModel` (value-shape walking, `ref`s resolved through the model's named schemas) and writes the element type into the operation's `Ops` entry as `item`. `.items()` yields that type with zero runtime reflection; the same resolution is what verification rides on, so a type that emits is a pointer that resolves. 3. **Capability-seam placement.** The runtime logic is one module, `runtime/paginate.ts` (`pages`/`items` generators + RFC 6901 `resolvePointer`), wired through `Capabilities.paginate` exactly like SSE: the send core never statically imports it, inline output embeds it only when some descriptor paginates, and an unwired capability throws descriptively. The descriptor gains an optional `pagination` field (normalized: `style`, `param`, `nextCursor?`, `limitParam?`, `items`) — optional, so the frozen contract holds and non-paginated package-mode output stays byte-identical; `runtime: package` clients pick up pagination fixes via `npm update`. diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md new file mode 100644 index 0000000000..fd8b937dba --- /dev/null +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -0,0 +1,131 @@ +# Writing custom client generators + +A generator is a plain module: `(input) => GeneratedFile[]`. It receives the +language-agnostic API model and returns files — in ANY output language. It runs +in the same pass as the built-ins; select it by path in `redocly.yaml`: + +```yaml +client: + generators: [typescript, ./generators/my-generator.mjs] +``` + +## The contract + +```js +/** @type {import('@redocly/client-generator').CustomGenerator} */ +export default { + name: 'my-generator', + run({ model, outputPath, outputMode, emit }) { + return [{ path: outputPath.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. + sample(operation, { model, emit }) { + return { lang: 'python', source: '…' }; + }, + // 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: '…' }]; + }, +}; +``` + +## Declaring options + +A generator that needs configuration declares it as a schema; `run` then receives +`options` already validated, with defaults applied: + +```js +export default { + name: 'permissions-matrix', + options: { + type: 'object', + properties: { groupBy: { enum: ['tag', 'path'], default: 'tag' } }, + additionalProperties: false, + }, + run({ model, outputPath, options }) { + return [ + { path: outputPath.replace(/\.ts$/, '.permissions.md'), content: render(options.groupBy) }, + ]; + }, +}; +``` + +Users set them per generator name: + +```yaml +client: + generators: [typescript, ./generators/permissions-matrix.mjs] + options: + permissions-matrix: + groupBy: path +``` + +The supported subset is a top-level `type: 'object'` with `properties`, `required`, and +`additionalProperties`; each property is a scalar (`string`/`number`/`boolean`), an +`enum`, or an array of scalars, and may carry a `default` and a `description`. Don't +validate options inside `run` — an unknown key, a wrong type, a value outside an `enum`, +or a missing `required` key already fails generation before `run` is called. + +Rules: output is deterministic (same description → same bytes); never add +dependencies to the generated client; **never hand-edit generated output** — +edit this generator and regenerate. Emitted file paths must stay inside the +`--output` directory (subdirectories are fine) — escapes are rejected. +Optionally declare `requiresGenerator` — the `@redocly/client-generator` version +range you wrote this against (`'^1.2.0'`, `'~1.2.0'`, `'>=1.2.0'`, or an exact +version). A CLI outside the range then fails with the fix path instead of feeding +your generator an unexpected model shape. Ejected generators carry it +automatically; hand-written ones without it are taken as current. + +## The model (IR) + +`model.services[].operations[]` — each operation carries `name`, `specName`, +`method`, `path`, `tags`, `pathParams`/`queryParams`/`headerParams`/`cookieParams`, +`requestBody`, `successResponses`/`errorResponses` (each with a `schema`), and +`security`. `model.schemas` holds the named schemas. Every schema is a +discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, +`literal`, `enum`, `union` (optionally with a discriminator), `intersection` +(allOf), `null`, `unknown`, `omit`. + +## 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). | + +Worked example: the built-in `python` generator +(`packages/client-generator/src/generators/python/index.ts` in the Redocly CLI repo) is +authored with exactly this toolkit and nothing else — models via `flattenAllOf`/ +`enumValues`/`discriminatorCases`, all code through `Printer`, every name through +`identifierFor(..., RESERVED_WORDS.python)`. + +A generator that emits TypeScript may additionally use the source-text renderers from +`@redocly/client-generator/generate` — `tsType` (schema → type), `tsJsdoc`, `codeLiteral`, +`operationSignature`, `pascalCase`, `safeIdent`. There is no AST toolkit and no +`typescript` dependency: every generator prints text through `Printer`. + +## The loop + +1. Edit the generator. +2. Run `redocly generate-client`. +3. Inspect `git diff` of the generated output. +4. Repeat. Generated files are never hand-edited. + +If you had to work around a **missing helper** or a wrong default, tell the user +and suggest filing an issue at https://github.com/Redocly/redocly-cli/issues — +include the generator snippet and the helper you expected to exist. diff --git a/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md b/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md new file mode 100644 index 0000000000..5aa8ca6dcc --- /dev/null +++ b/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md @@ -0,0 +1,112 @@ +--- +name: cli-generator +description: Design of the ejected Redocly `cli` client generator. Read it, and update it, before changing generators/cli.mjs. +--- + +# The `cli` generator — its skill + +This file is the DESIGN of your ejected `cli` generator (`generators/cli.mjs`): +**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. + +## What it emits + +A bin-ready `.cli.ts`: one command per operation over the sdk's instance client, +with `--help`, a `schema ` introspection command, and `--dry-run`. + +With `client.docs` (or `--docs`), the `docs` hook also writes `.cli.md`: the usage +line, the global flags, the credential variables, the exit-code table, and one section per +command with its positionals and flags. + +## Design decisions that must hold + +- **Argument shape:** path params positional, query params typed `--kebab-name` flags, + JSON bodies via `--json '' | @file | @-` (stdin). +- **Help is the whole interface.** A flag that exists but isn't in `--help` doesn't exist + to the user, so the top-level help carries a `Global flags:` section (`--server-url`, + `--format`, `--dry-run`, `--page-all`, `--output`, `--token`, `--json`) plus the + credential environment variables. Descriptions are collapsed to ONE line — an OpenAPI + description with newlines otherwise breaks the alignment of every following flag. The + footer names the form that actually works for a grouped API + (` --help`). +- **Commands are addressable the way a shell allows.** A group slug is kebab-cased so a + multi-word OpenAPI tag can be typed without quoting, while help shows the original tag. + A bare operationId resolves to its grouped command when unambiguous. +- **Exit codes are a contract:** 0 ok, 1 API error, 2 auth, 3 validation, 4 usage. + Errors print ONE JSON object to stderr so stdout stays pipeable. +- **The CLI names itself from `process.argv[1]`.** Only the operator's `bin` field decides + what the command is called, so help reads the invoked name back instead of printing a + name from generation that may not exist on the machine. +- **Credentials come from the environment** — `wiring.envPrefix`, the constant-cased output + stem (`CLIENT_TOKEN`), which a composed entry sets per api alias — or explicit flags; + `--dry-run` prints the prepared request with credentials REDACTED. The prefix is fixed at + generation on purpose: a renamed binary must keep reading the variables a published CLI + already documents. Help lists only the credentials the description declares, and an + unusable `--token` is a usage error, never silently dropped. +- **Validation is on by default.** The generator declares `requires: ['typescript', 'zod']` and + the pipeline pulls prerequisites in automatically, so `--generator cli` alone produces a + validating CLI — a user shouldn't have to know which other generator provides it. The + consequence is a zod peer dependency at run time, which the docs state. +- Throw-mode only — the exit-code mapping reads thrown `ApiError`s. +- **Runs under `node --experimental-strip-types` with no build step**, including the + modules it imports (the sdk and the zod module). Anything emitted must be erasable + TypeScript; a parameter property anywhere in that import graph breaks the zero-build + runner. +- **The generated module is a library as well as a binary.** It exports `COMMANDS`, + `wiring`, and `run`, and self-executes only when it is the process entry — a REALPATH + comparison of `import.meta.url` against `argv[1]`, because some runners resolve + symlinks in one but not the other (macOS temp dirs, installed bin symlinks), and a + plain URL comparison silently runs nothing. `import.meta.main` would be cleaner but is + absent from our Node floors. Importing the module must be side-effect-safe: + module-level wiring (zod validation) touches only the module's OWN client, never a + global. +- **Behavior that is not in the description is composed, never generated.** A custom + command (`login`, anything) is the operation-command data shape plus a `handler`, so it + inherits help, parsing, `schema`, and the exit-code contract; `runCli` dispatches it + instead of the client. The generator itself never learns what such a command does — + credentials files, login flows, and profiles are user land (or a future satellite), + by design. +- **One binary can span several descriptions.** `runCli` also accepts sources — each a + command list plus, optionally, its OWN wiring (own base URL, schemes, credentials) + behind a namespace, so colliding operationIds across descriptions are simply different + commands (`cafe shop createOrder`, `cafe kitchen createOrder`). A namespace-less source + puts commands at the root (`cafe login`); a root command whose name matches a namespace + is rejected at startup, never shadowed. A source WITHOUT wiring inherits the first + wired source's — a root `login` shares the composed binary's identity, which is the + whole point of composing it there. +- **The composed entry is generated, not hand-rolled.** A top-level `client.cliOutput` + makes `redocly generate-client` (no api argument) emit one entry over every api that + selected `cli`: the namespace is the api ALIAS from `apis:`, and the credential prefix + defaults to `_` (`CAFE_SHOP_TOKEN`) via `wiring.envPrefix` — which + exists precisely so the display name and the credential prefix can differ. The composed + entry exports its `SOURCES` so an adopter layers custom commands around it without + editing a generated file. Without `cliOutput`, nothing changes. + +- **The CLI documents itself.** The page is this generator's `docs` hook, not a separate + generator: nothing else knows this tool's commands, and a reader who ejects `cli` gets + the page layout with it. The page renders from `commandData` — the same table `runCli` + dispatches on — so it cannot describe a tool other than the one beside it. A capability + reaches the page only by being in that table. The page is Markdown that survives a + linter (ATX headings, a blank line around every block, no hard tabs, one sentence per + line) and it escapes what descriptions contain, because a summary is arbitrary text. + +## Emitters that implement it + +`emitters/cli.ts` (commands + module) and `emitters/cli-docs.ts` (the page), plus the +sdk's operation types. + +## Ejecting it + +`redocly eject-generator cli` ships this generator BUNDLED with the emitters it uses — one +`.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. Change +the command surface, the help layout, or the exit-code mapping, and regenerate. The exit +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. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator cli --update`. diff --git a/packages/client-generator/eject-assets/skills/client-generators/SKILL.md b/packages/client-generator/eject-assets/skills/client-generators/SKILL.md new file mode 100644 index 0000000000..3a0250145a --- /dev/null +++ b/packages/client-generator/eject-assets/skills/client-generators/SKILL.md @@ -0,0 +1,136 @@ +--- +name: client-generators +description: Write or change a Redocly client generator — the API model, the language-neutral helper toolkit, and the edit → regenerate → diff loop. +--- + +# Writing custom client generators + +A generator is a plain module: `(input) => GeneratedFile[]`. It receives the +language-agnostic API model and returns files — in ANY output language. It runs +in the same pass as the built-ins; select it by path in `redocly.yaml`: + +```yaml +client: + generators: [typescript, ./generators/my-generator.mjs] +``` + +## The contract + +```js +/** @type {import('@redocly/client-generator').CustomGenerator} */ +export default { + name: 'my-generator', + run({ model, outputPath, outputMode, emit }) { + return [{ path: outputPath.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. + sample(operation, { model, emit }) { + return { lang: 'python', source: '…' }; + }, + // 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: '…' }]; + }, +}; +``` + +## Declaring options + +A generator that needs configuration declares it as a schema; `run` then receives +`options` already validated, with defaults applied: + +```js +export default { + name: 'permissions-matrix', + options: { + type: 'object', + properties: { groupBy: { enum: ['tag', 'path'], default: 'tag' } }, + additionalProperties: false, + }, + run({ model, outputPath, options }) { + return [ + { path: outputPath.replace(/\.ts$/, '.permissions.md'), content: render(options.groupBy) }, + ]; + }, +}; +``` + +Users set them per generator name: + +```yaml +client: + generators: [typescript, ./generators/permissions-matrix.mjs] + options: + permissions-matrix: + groupBy: path +``` + +The supported subset is a top-level `type: 'object'` with `properties`, `required`, and +`additionalProperties`; each property is a scalar (`string`/`number`/`boolean`), an +`enum`, or an array of scalars, and may carry a `default` and a `description`. Don't +validate options inside `run` — an unknown key, a wrong type, a value outside an `enum`, +or a missing `required` key already fails generation before `run` is called. + +Rules: output is deterministic (same description → same bytes); never add +dependencies to the generated client; **never hand-edit generated output** — +edit this generator and regenerate. Emitted file paths must stay inside the +`--output` directory (subdirectories are fine) — escapes are rejected. +Optionally declare `requiresGenerator` — the `@redocly/client-generator` version +range you wrote this against (`'^1.2.0'`, `'~1.2.0'`, `'>=1.2.0'`, or an exact +version). A CLI outside the range then fails with the fix path instead of feeding +your generator an unexpected model shape. Ejected generators carry it +automatically; hand-written ones without it are taken as current. + +## The model (IR) + +`model.services[].operations[]` — each operation carries `name`, `specName`, +`method`, `path`, `tags`, `pathParams`/`queryParams`/`headerParams`/`cookieParams`, +`requestBody`, `successResponses`/`errorResponses` (each with a `schema`), and +`security`. `model.schemas` holds the named schemas. Every schema is a +discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, +`literal`, `enum`, `union` (optionally with a discriminator), `intersection` +(allOf), `null`, `unknown`, `omit`. + +## 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). | + +Worked example: the built-in `python` generator +(`packages/client-generator/src/generators/python/index.ts` in the Redocly CLI repo) is +authored with exactly this toolkit and nothing else — models via `flattenAllOf`/ +`enumValues`/`discriminatorCases`, all code through `Printer`, every name through +`identifierFor(..., RESERVED_WORDS.python)`. + +A generator that emits TypeScript may additionally use the source-text renderers from +`@redocly/client-generator/generate` — `tsType` (schema → type), `tsJsdoc`, `codeLiteral`, +`operationSignature`, `pascalCase`, `safeIdent`. There is no AST toolkit and no +`typescript` dependency: every generator prints text through `Printer`. + +## The loop + +1. Edit the generator. +2. Run `redocly generate-client`. +3. Inspect `git diff` of the generated output. +4. Repeat. Generated files are never hand-edited. + +If you had to work around a **missing helper** or a wrong default, tell the user +and suggest filing an issue at https://github.com/Redocly/redocly-cli/issues — +include the generator snippet and the helper you expected to exist. diff --git a/packages/client-generator/eject-assets/skills/go-generator/SKILL.md b/packages/client-generator/eject-assets/skills/go-generator/SKILL.md new file mode 100644 index 0000000000..d461feb9e9 --- /dev/null +++ b/packages/client-generator/eject-assets/skills/go-generator/SKILL.md @@ -0,0 +1,94 @@ +--- +name: go-generator +description: Design of the ejected Redocly `go` client generator. Read it, and update it, before changing generators/go.mjs. +--- + +# The `go` generator — its skill + +This file is the DESIGN of your ejected `go` generator (`generators/go.mjs`): +**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. + +## What it emits + +One self-contained `.go` (`package client`): structs with `json` tags, a `Client` +with one `(T, error)` method per operation taking a `context.Context`, and the embedded +runtime. Go ≥ 1.21, standard library only — zero dependencies. + +## Design decisions that must hold + +- **Models are structs**: required fields by value, optionals as pointers with + `,omitempty`; the `json` tag always carries the exact wire name. +- **Package clause:** `package client` by default, `goPackage` to override — a generated + file usually lands in a package the consumer already owns. The value is checked against + Go's own rule (lowercase letters, digits, `_`, no leading digit, not a keyword) and an + invalid one fails generation: silently rewriting a publisher's package name would be + worse than saying no. +- **Doc comments are gofmt's shape**, not the description's: a blank line prints as `//` + (never `// `, which gofmt strips), and CONSECUTIVE blank lines collapse to one — gofmt + rewrites `//\n//` to a single `//`, so emitting both means our output is not + gofmt-clean. Descriptions with a double blank line are common in real specs. +- **Every parameter is its own argument, so their names share one namespace** with the + arguments the method declares itself (`ctx`, `body`, `params`, and the receiver). Build them with + `uniqueIdentifiers(..., { taken: … })`: OpenAPI lets one operation use a name in two + locations (`id` in the path AND in the query), and Go rejects a duplicate parameter. The + wire name is untouched, so the request is unchanged. +- **Naming:** exported PascalCase via `identifierFor` + an `N` prefix for digit-leading + names (`3ds` → `N3ds` — an `_`-prefixed field is unexported and invisible to + `encoding/json`); `+1`/`-1` become `Plus1`/`Minus1`. +- **Enums** are typed consts (`type Status string` + `StatusInProgress Status = …`); + **discriminated unions** are `type X = any` plus a generated `UnmarshalX([]byte)` + dispatcher; **allOf** is flattened. +- **Errors:** `(T, error)` returns ARE the error mode — `errorMode` does not change the + output (the generator declares `errorModes: ['throw']`, so `result` fails fast). + Non-2xx → `*APIError`; timeouts → `*TimeoutError`. +- **Dates:** `dateType: Date` maps `format: date-time` to `time.Time` (encoding/json + handles RFC 3339 natively) and `date` to the runtime's `Date` wrapper, which + marshals as `2006-01-02`. Query values format explicitly, never via `String()`. +- **Response headers:** an operation that DECLARES success-response headers gains a + `WithHeaders(ctx, …) (T, Headers, error)` variant; `Headers` is a + generated struct with pointer fields (nil when absent or unparsable), coerced to + int64/bool/string. Operations without declared headers get no variant, and the + base method stays `(T, error)`. +- **Servers:** when the description declares servers, one `URL(...)` function per + server is emitted (named from the server description); server VARIABLES become string + parameters (Go has no defaults — the doc comment states the spec default), so templated + base URLs need no manual string building. The client's baked default stays `servers[0]` + with variable defaults substituted. +- **Parity surface:** auth, retries with `Retry-After` + jittered backoff, per-attempt + `context.WithTimeout`, idempotency keys, middleware, pagination (`Pages`/`Items` + as `func(yield func(T, error) bool)` — `range`-over-func needs Go ≥ 1.23; 1.21 calls + them with a callback), SSE, multipart. +- **The EMITTED FILE is gofmt-clean, not just the runtime.** `gofmt -l` on generated + output must print nothing, so the download is idiomatic as-is. The emitter earns that + deterministically, without shelling out to `gofmt`: + - `alignGoColumns` pads columns the way gofmt's tabwriter does — struct field types and + tags, `const`/`var` types and `=`, and map-literal values — within each contiguous run. + A line starting with a Go KEYWORD is a statement, never a declaration, and must never + be padded (`case "x":` is not a field). + - `case` sits at its `switch`'s own indent, so the switch body is not emitted as an + indented block. + - At most one blank line between declarations, none at end of file, and a blank line + 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) + and embedded at prepare time. +- Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. + +- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes + `.go.md`: the security schemes, then one section per operation with its parameters, + body, response type, and behavior notes. The call snippets come from this generator's own + `sample` hook, so the page can only show the syntax of the SDK beside it, and the layout + comes from `renderReferencePage` in the authoring toolkit — reachable from an ejected copy + through `@redocly/client-generator`. Pagination on the page is decided by + `paginationRuleFor`, the same helper this generator resolves pagination with. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/go.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator go --update`. diff --git a/packages/client-generator/eject-assets/skills/mock-generator/SKILL.md b/packages/client-generator/eject-assets/skills/mock-generator/SKILL.md new file mode 100644 index 0000000000..b46113901b --- /dev/null +++ b/packages/client-generator/eject-assets/skills/mock-generator/SKILL.md @@ -0,0 +1,45 @@ +--- +name: mock-generator +description: Design of the ejected Redocly `mock` client generator. Read it, and update it, before changing generators/mock.mjs. +--- + +# The `mock` generator — its skill + +This file is the DESIGN of your ejected `mock` generator (`generators/mock.mjs`): +**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. + +## What it emits + +A standalone MSW module: `create()` data factories, `Handler()` / +`ErrorHandler(status, body?)` request handlers, and a `handlers` array. + +## Design decisions that must hold + +- **Two data modes:** `mockData: static` bakes deterministic samples from the schema + (examples/defaults first); `faker` emits `faker.*` calls with a seed (`mockSeed`) so + runs are reproducible. +- **Interpolated identifiers are gated** (`codeIdent`): an operation name or method + reaching a code position is validated, never trusted, even though the pipeline + sanitizes upstream. +- Handlers are opt-in overrides: `ErrorHandler` is NOT in `handlers`. +- The module references the sdk's TYPES only — never its runtime. + +## Emitters that implement it + +`emitters/mock.ts`, `mock-value.ts` (data trees), `faker.ts`, `sample.ts`. + +## Ejecting it + +`redocly eject-generator mock` ships this generator BUNDLED with the emitter it uses — one +small `.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. +Change the data strategy, the handler shape, or the factory surface, and regenerate. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/mock.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator mock --update`. diff --git a/packages/client-generator/eject-assets/skills/php-generator/SKILL.md b/packages/client-generator/eject-assets/skills/php-generator/SKILL.md new file mode 100644 index 0000000000..acfed7323e --- /dev/null +++ b/packages/client-generator/eject-assets/skills/php-generator/SKILL.md @@ -0,0 +1,110 @@ +--- +name: php-generator +description: Design of the ejected Redocly `php` client generator. Read it, and update it, before changing generators/php.mjs. +--- + +# The `php` generator — its skill + +This file is the DESIGN of your ejected `php` generator (`generators/php.mjs`): +**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. + +## What it emits + +One self-contained `.php`: promoted-constructor model classes, a `Client` with one +typed method per operation, and the embedded runtime. PHP ≥ 8.1, HTTP over the curl +extension — zero Composer dependencies. The namespace derives from the API title +(`identifierFor(title, pascal)` — e.g. `CafeOrders`). + +## Design decisions that must hold + +- **Models are `final class`es** with constructor property promotion, required parameters + first, optionals nullable `= null`. Hydration is compile-time generated per class: + `fromArray(array $data): self` and `toArray(): array` (wire names inline; nulls + skipped on serialize) — no reflection. `omit` schemas hydrate/serialize through their + base class. A property or response typed as a DISCRIMINATED union hydrates through the + union's `unmarshalX` dispatcher, so consumers can narrow with `instanceof`; + undiscriminated unions stay raw arrays. +- The `Client` class is NOT `final` — PHP test suites mock concrete classes + (`createMock(Client::class)`), and `final` would force a wrapper interface on every + consumer. Model classes stay `final`. +- **Every parameter is its own argument, so their names share one namespace** with the + arguments the method declares itself (`$body`, `$headers`, `$idempotencyKey`). Build them with + `uniqueIdentifiers(..., { taken: … })`: OpenAPI lets one operation use a name in two + locations (`id` in the path AND in the query), and PHP rejects a redefined parameter outright. The + wire name is untouched, so the request is unchanged. +- **Naming:** classes PascalCase, properties/methods camelCase via + `identifierFor(..., RESERVED_WORDS.php)`; reserved words get a trailing underscore. +- **Enums** are native backed enums (string/int); other scalars stay aliases. + **Discriminated unions** are `match`-based `unmarshalX(array $data)` dispatchers; + **allOf** is flattened. +- **Unions keep their types where PHP 8.1 can express them.** A union of scalars, enums, + classes, or arrays becomes a native union type (`int|string`, `PromotionType|array`) + rather than collapsing to `mixed` — rich list filters are the common case and losing + their types loses the point of a typed SDK. It falls back to `mixed` only when a member + has no PHP type of its own (an inline object, an intersection, `unknown`), because + `mixed` cannot appear inside a union. Nullability is expressed as `|null` in a union + (PHP forbids mixing `?` with `|`) and `?T` for a single type. +- **Errors:** exceptions ARE the error mode (`ApiError`/`TimeoutError` extend + `\RuntimeException`); `errorMode` does not change the output (the generator declares + `errorModes: ['throw']`, so `result` fails fast). +- **Dates:** `dateType: Date` types `format: date`/`date-time` as + `\DateTimeImmutable`; hydration is `new \DateTimeImmutable(...)` and serialization + formats with `\DateTimeInterface::ATOM` (date-time) or `'Y-m-d'` (date), including + for query parameters. +- **Method arguments:** required path params positional, JSON body next, optional query + params as nullable NAMED arguments, then `?array $headers`, and `?string +$idempotencyKey` on mutating methods. +- **Non-JSON success bodies** (PDFs, images, octet streams) return the raw body as + `string` — a binary download must never degrade to `void`. +- **PHPDoc carries what the signature cannot.** PHP's `array` and `\Generator` erase their + element type, so a docblock states it: `@return Customer[]` for collection returns and + `@return \Generator` on `Pages()`/`Items()`. Static analysis and + readers go by these; a hydrated return with no annotation looks untyped. +- **Response headers:** an operation that DECLARES success-response headers gains a + `WithHeaders()` variant returning an `Envelope` (`data`, `headers` — coerced to + int/bool/string with camelCase keys, absent/unparsable values omitted — and `status`). + Operations without declared headers get no variant, and the base method stays + body-only (PHP cannot vary a return type on a flag). +- **Servers:** when the description declares servers, a `Servers` class is emitted with + one static method per server; server VARIABLES become named string arguments defaulting + to the spec's defaults (`Servers::production(organizationId: 'org_x')`), so templated + base URLs need no manual string building. The client's baked default stays `servers[0]` + with variable defaults substituted. +- **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 + at prepare time. `curl_close` is never called (deprecated since PHP 8.5, no-op since 8.0). +- Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. + +## Migrating from a service-based SDK + +- Per-resource services (`$client->customers()->get($id)`) map to flat methods named + after operationIds (`$client->getCustomer($id)`); optional query params keep their + named-argument style (`filter:`, `sort:`, `limit:`). +- Collection wrappers exposing pagination RESPONSE HEADERS (`getTotalItems()`, + `getLimit()`) map to the `WithHeaders()` envelope + (`->headers['paginationTotal']`); plain iteration maps to `Items()` / + `Pages()` generators. +- Dedicated validation-exception classes exposing field errors map to + `catch (ApiError $e)` + `$e->status === 422` + the decoded `$e->body`. +- Session/bearer token flows map to `auth: ['bearer' => $tokenProvider]` with a + callable — resolved per request, so refresh needs no client rebuild. + +- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes + `.php.md`: the security schemes, then one section per operation with its parameters, + body, response type, and behavior notes. The call snippets come from this generator's own + `sample` hook, so the page can only show the syntax of the SDK beside it, and the layout + comes from `renderReferencePage` in the authoring toolkit — reachable from an ejected copy + through `@redocly/client-generator`. Pagination on the page is decided by + `paginationRuleFor`, the same helper this generator resolves pagination with. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/php.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator php --update`. diff --git a/packages/client-generator/eject-assets/skills/python-generator/SKILL.md b/packages/client-generator/eject-assets/skills/python-generator/SKILL.md new file mode 100644 index 0000000000..29204fe6d9 --- /dev/null +++ b/packages/client-generator/eject-assets/skills/python-generator/SKILL.md @@ -0,0 +1,108 @@ +--- +name: python-generator +description: Design of the ejected Redocly `python` client generator. Read it, and update it, before changing generators/python.mjs. +--- + +# The `python` generator — its skill + +This file is the DESIGN of your ejected `python` generator (`generators/python.mjs`): +**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. + +## What it emits + +One self-contained `.py`: typed dataclass models, a sync `Client` and an async +`AsyncClient`, and the embedded runtime. Python ≥ 3.9; the only dependency is +[httpx](https://www.python-httpx.org/) (`pip install httpx`). + +## Design decisions that must hold + +- **The file name is an importable module name.** The `--output` stem follows the TypeScript + convention (`openapi.client.ts`), and `openapi.client.py` cannot be imported by name — nor + can hyphens or a leading digit. The stem is converted with + `identifierFor(stem, snake)`, so `rebilly-core.client.ts` emits + `rebilly_core_client.py` and `import rebilly_core_client` just works. + +- **Models are dataclasses by default**, required fields first (a dataclass constraint), + optionals `Optional[T] = None`. Wire names live in a `_field_map: ClassVar[Dict[str, str]]`; + decode/encode is reflective (`_decode.py`, `get_type_hints`) — no per-model codecs. +- **`models: pydantic` emits `BaseModel` classes instead**, for the FastAPI-shaped half of + the ecosystem that expects them. A wire name becomes `Field(alias=…)` with + `populate_by_name=True`, so `_field_map` is not emitted in this mode — the alias is the + mapping. Everything else is unchanged: the same class names, the same field names, the + same `Optional[T] = None`, the same enums and union aliases, the same client and runtime. + Switching modes must not change a call site. +- **A discriminated union carries its discriminator into the pydantic annotation.** 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 that dataclass mode + walks. Pydantic resolves it correctly from `Annotated[Union[...], Field(discriminator=…)]`, + which it accepts only when every member types that property as a `Literal` — and the + mapping already pins one value per member, so the members get `Literal["cat"]`. Such a + union registers no table entry: pydantic owns it at every depth, and the `Literal` makes + the decoder's member probe exact. A union whose members never declare the property keeps + the plain `Union` and the table entry, and pydantic then matches nested members its own + way — the description is what has to change there. +- **One runtime serves both model modes.** `_decode.py` dispatches on the target: a class + with `model_validate` is validated by pydantic, a dataclass is hydrated reflectively, and + `encode` mirrors that with `model_dump(by_alias=True, exclude_none=True, mode="json")`. + A second runtime variant per mode would double the surface that has to stay in step, and + pydantic's `ValidationError` already subclasses `ValueError`, so union member probing + needs no new except clause. +- **`models: pydantic` adds a dependency, and the header says so.** The default mode keeps + httpx as the only requirement; the pydantic header asks for both. A mode that quietly + needed a package the file never named would fail at import with nothing to act on. +- **Every parameter is its own argument, so their names share one namespace** with the + arguments the method declares itself (`body`, `headers`, `timeout`, `retry`, `idempotency_key`). Build them with + `uniqueIdentifiers(..., { taken: … })`: OpenAPI lets one operation use a name in two + locations (`id` in the path AND in the query), and a `def` that declared one name twice is a `SyntaxError`. The + wire name is untouched, so the request is unchanged. +- **Naming:** fields/methods snake*case via `identifierFor(..., RESERVED_WORDS.python)`; + reserved words get a trailing underscore (`class*`); `+1`/`-1`become`plus_1`/`minus_1`. +- **Enums** are `class X(str, Enum)` with SCREAMING members; **unions** are `Union[...]` + aliases. A DISCRIMINATED union registers its dispatch table in the runtime's + `DISCRIMINATORS` registry (`DISCRIMINATORS[Pet] = ("petType", {"cat": Cat, ...})`), + and `decode()` routes through it — `isinstance` narrowing works on decoded members. + Undiscriminated unions decode by trying each member in order (the first that + hydrates wins — see `_decode.py`). **allOf** is flattened via `flattenAllOf`. +- **Auth keys match the other languages.** `auth={"apiKey": {...}}` is the documented key — + the same spelling TypeScript and PHP use, and the same as the scheme kind — with + `api_key` accepted as an alias so a snake_case config keeps working. +- **Errors:** `errorMode` maps to raising `ApiError` (default) or returning a `Result` + dataclass — the only generator with both modes outside TypeScript. +- **Dates:** `dateType: Date` annotates `format: date-time` as `datetime` and `date` as + `date`; `_decode.py` parses ISO strings into them and `encode()` writes `isoformat()` + back. The default (`string`) keeps the wire shape. +- **Response headers:** an operation that DECLARES success-response headers gains a + `_with_headers()` variant (sync and async) returning `Envelope[T]` — `data`, + `headers` (coerced to int/bool/str with snake_case keys; absent/unparsable values + omitted), and the raw `response`. Operations without declared headers get no + variant, and the base method stays body-only. +- **Servers:** when the description declares servers, a `Servers` class is emitted with + one static method per server; server VARIABLES become keyword arguments defaulting to + the spec's defaults (`Servers.production(organization_id="org_x")`), so templated base + URLs need no manual string building. The client's baked default stays `servers[0]` + with variable defaults substituted. +- **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 + time — generator code never builds runtime logic from templates. +- Authored ONLY with the neutral toolkit (`Printer`, naming, schema, pagination helpers) — + the dogfooding guard fails otherwise. + +- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes + `.python.md`: the security schemes, then one section per operation with its parameters, + body, response type, and behavior notes. The call snippets come from this generator's own + `sample` hook, so the page can only show the syntax of the SDK beside it, and the layout + comes from `renderReferencePage` in the authoring toolkit — reachable from an ejected copy + through `@redocly/client-generator`. Pagination on the page is decided by + `paginationRuleFor`, the same helper this generator resolves pagination with. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/python.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator python --update`. diff --git a/packages/client-generator/eject-assets/skills/swr-generator/SKILL.md b/packages/client-generator/eject-assets/skills/swr-generator/SKILL.md new file mode 100644 index 0000000000..e0fc15fef2 --- /dev/null +++ b/packages/client-generator/eject-assets/skills/swr-generator/SKILL.md @@ -0,0 +1,44 @@ +--- +name: swr-generator +description: Design of the ejected Redocly `swr` client generator. Read it, and update it, before changing generators/swr.mjs. +--- + +# The `swr` generator — its skill + +This file is the DESIGN of your ejected `swr` generator (`generators/swr.mjs`): +**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. + +## What it emits + +React SWR hooks over the sdk's exported operation functions: `use()` with a +`Key()` key factory for queries, `useSWRMutation` for mutations. + +## Design decisions that must hold + +- **Wraps the sdk's functions** — it never re-implements requests, so it requires `typescript` + and is throw-mode only. +- **Keys are exported factories** so consumers can invalidate precisely. +- **`envelope` is excluded** from hook options (`Omit`) and + stripped from the forwarded call: cached data is always the plain body. +- **Skips what it cannot wrap** — SSE operations and `Variables` name collisions — + with a warning naming each one, never silently. + +## Emitters that implement it + +`emitters/swr.ts`, `wrapper-support.ts` (shared wrappable-operation policy). + +## Ejecting it + +`redocly eject-generator swr` ships this generator BUNDLED with the emitter it uses — one +small `.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. +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. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator swr --update`. 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 new file mode 100644 index 0000000000..96f9cd4e80 --- /dev/null +++ b/packages/client-generator/eject-assets/skills/tanstack-query-generator/SKILL.md @@ -0,0 +1,48 @@ +--- +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. +--- + +# The `tanstack-query` generator — its skill + +This file is the DESIGN of your ejected `tanstack-query` generator (`generators/tanstack-query.mjs`): +**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. + +## What it emits + +Query/mutation option factories for TanStack Query — `Options()`, +`Mutation()`, and `InfiniteOptions()` for paginated operations — plus exported +query keys. One generator, four framework variants (`react` default, `-vue`, +`-svelte`, `-solid`) differing only in the imported package. + +## Design decisions that must hold + +- **Options factories, not hooks:** consumers call `useQuery(Options(...))`, so the + output works with any of the framework adapters and stays testable. +- **`queryKeyPrefix`** namespaces every key when several clients share a cache. +- **Infinite queries** derive `getNextPageParam` from the resolved pagination rule; a + `link`-style rule reads the `Link` header the descriptor declares. +- **`envelope` is excluded and stripped** — cached data is the plain body. +- Requires `typescript`; throw-mode only (it wraps thrown errors into query errors). + +## Emitters that implement it + +`emitters/tanstack-query.ts`, `wrapper-support.ts`, `pagination.ts`. + +## Ejecting it + +`redocly eject-generator tanstack-query` ships this generator BUNDLED with the emitter it +uses — one small `.mjs` you own, importing `@redocly/client-generator` and +`@redocly/openapi-core`. The framework is a single argument in the ejected file's default +export (`tanstackQueryGenerator('react')`), so switch it to `'vue'`, `'svelte'`, or +`'solid'` there instead of ejecting four near-identical copies. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/tanstack-query.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator tanstack-query --update`. diff --git a/packages/client-generator/eject-assets/skills/transformers-generator/SKILL.md b/packages/client-generator/eject-assets/skills/transformers-generator/SKILL.md new file mode 100644 index 0000000000..62c09908c7 --- /dev/null +++ b/packages/client-generator/eject-assets/skills/transformers-generator/SKILL.md @@ -0,0 +1,43 @@ +--- +name: transformers-generator +description: Design of the ejected Redocly `transformers` client generator. Read it, and update it, before changing generators/transformers.mjs. +--- + +# The `transformers` generator — its skill + +This file is the DESIGN of your ejected `transformers` generator (`generators/transformers.mjs`): +**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. + +## What it emits + +Per-schema `to()` / `from()` converters that turn wire JSON into typed +values and back — the bridge for `dateType: Date` clients. + +## Design decisions that must hold + +- **Requires `dateType: Date`** (declared as `dateTypes: ['Date']`, so a mismatched + selection fails fast): the converters assign `Date` objects to fields the sdk types as + `Date`, which only type-checks in that mode. +- **Imports the sdk's schema TYPES** (so `typescript` is required) and nothing else. +- Converters are pure and total: every named schema gets a pair, nested structures + recurse, and a missing optional stays missing. + +## Emitters that implement it + +`emitters/transformers.ts`. + +## Ejecting it + +`redocly eject-generator transformers` ships this generator BUNDLED with the emitter it +uses — one small `.mjs` you own, importing `@redocly/client-generator` and +`@redocly/openapi-core`. Change which fields are converted, or how, and regenerate. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/transformers.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator transformers --update`. diff --git a/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md b/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md new file mode 100644 index 0000000000..bede4fa750 --- /dev/null +++ b/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md @@ -0,0 +1,80 @@ +--- +name: typescript-generator +description: Design of the ejected Redocly `typescript` client generator. Read it, and update it, before changing generators/typescript.mjs. +--- + +# The `typescript` generator — its skill + +This file is the DESIGN of your ejected `typescript` generator (`generators/typescript.mjs`): +**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. + +## What it emits + +The typed TypeScript client itself: model types with JSDoc, type guards, the `Ops` +type map, the `OPERATIONS` descriptor table, a `client` instance, one binding per +operation, and either the embedded runtime (`runtime: inline`) or imports from +`@redocly/client-generator` (`runtime: package`). + +## Design decisions that must hold + +- **Descriptor-driven:** generated code is DATA (`OPERATIONS` + `Ops`) plus wiring; + request behavior lives in the runtime, never in per-operation code. + `satisfies Record` is the version-skew guard. +- **`single` vs `split`:** split derives `.schemas.ts` (types, enums, guards) and + an entry that `export *`s it; the entry type-imports only the schema names it + references (`collectEntrySchemaRefs`). +- **Zero runtime dependencies.** `Date`, `Blob`, `fetch` — nothing else. +- **Names are collision-safe:** `packageIdents` seeds every reserved wiring name before + any operation is sanitized, so renames are deterministic (`configure` → `configure_2`). + A rename becomes part of the SDK's public API, so the warning must say WHICH cause it + is and what the publisher can do: a duplicate `operationId` in the description (fix the + description — the only real fix), a name that isn't a valid identifier, or a clash with + a name the generated module already declares. A vague "collides or is invalid" message + leaves the publisher unable to act. +- **One operation, one function, one input shape.** The module-level names are bindings + of the client's own methods (`export const { getOrder } = client;`), never wrappers, so + `getOrder` and `client.getOrder` cannot disagree about their arguments. `argsStyle` + shapes the method itself: `grouped` (the default) namespaces the inputs by transport + layer — `path`, `query`, `headers`, `cookies`, `body` — and `flat` merges them into one + object, which the runtime converts back using the descriptor's own parameter list. An + operation whose merged names would collide keeps the grouped shape. +- **Throw mode returns the body**; `{ envelope: true }` opts into + `{ data, headers, response }` with typed declared headers. Result mode returns + `{ data, error, response }` and ignores `envelope`. + +## Emitters that implement it + +`emitters/client-assembly.ts` (orchestration), `render-client.ts` (Ops, aliases, input +shapes), `descriptor.ts`, `ts-type.ts`/`ts-literal.ts` (type + data text), `sse.ts`, +`pagination.ts`, `response-headers.ts`, `inline-runtime.ts`, `setup-bake.ts`. + +## Ejecting it + +`redocly eject-generator typescript` ships this generator BUNDLED with the emitters it uses — +one `.mjs` you own, unminified, with a comment marking each source module. It imports +only `@redocly/client-generator` (the toolkit and the embedded runtime) and +`@redocly/openapi-core` (`logger`, `isPlainObject`), so runtime fixes still arrive by +`npm update`. + +It is the largest of them (the whole client emitter plus the runtime it embeds), so reach +for the smaller paths first when they fit: `client.setup` bakes publisher defaults into the +generated client, and middleware or `configure()` change behavior at run time rather than +generation time. + +- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes + `.typescript.md`: the security schemes, then one section per operation with its parameters, + body, response type, and behavior notes. The call snippets come from this generator's own + `sample` hook, so the page can only show the syntax of the SDK beside it, and the layout + comes from `renderReferencePage` in the authoring toolkit — reachable from an ejected copy + through `@redocly/client-generator`. Pagination on the page is decided by + `paginationRuleFor`, the same helper this generator resolves pagination with. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/typescript.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator typescript --update`. diff --git a/packages/client-generator/eject-assets/skills/zod-generator/SKILL.md b/packages/client-generator/eject-assets/skills/zod-generator/SKILL.md new file mode 100644 index 0000000000..4e6ea0e987 --- /dev/null +++ b/packages/client-generator/eject-assets/skills/zod-generator/SKILL.md @@ -0,0 +1,49 @@ +--- +name: zod-generator +description: Design of the ejected Redocly `zod` client generator. Read it, and update it, before changing generators/zod.mjs. +--- + +# The `zod` generator — its skill + +This file is the DESIGN of your ejected `zod` generator (`generators/zod.mjs`): +**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. + +## What it emits + +A standalone `.zod.ts`: one `export const Schema` per named IR schema, the +`operationSchemas` request/response map, and a `zodValidation()` middleware. + +## Design decisions that must hold + +- **The client stays dependency-free.** zod is the CONSUMER's peer dependency; the + generated client never imports this module, and this module never imports the client. +- **Output-mode-agnostic:** one module beside the client whatever the sdk's layout. +- **Emits nothing** when the model has neither named schemas nor JSON operation bodies — + an empty file is worse than no file. +- Validation is opt-in at runtime (`use(zodValidation())`), never automatic. +- **Only ERASABLE TypeScript.** The module must run under `node --experimental-strip-types` + with no build step, so nothing that needs a transform is emitted: no `enum`, no + `namespace`, and no constructor parameter properties. `ZodValidationError` therefore + declares its fields and assigns them in the constructor body — `constructor(readonly +operationId: string)` fails strip-only mode, which is how the generated CLI broke when it + imported this module. + +## Emitters that implement it + +`emitters/zod.ts` (schema expressions + module assembly). + +## Ejecting it + +`redocly eject-generator zod` ships this generator BUNDLED with the emitter it uses — one +small `.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. +Change the schema shapes, the naming, or what gets a schema at all, and regenerate. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/zod.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator zod --update`. diff --git a/packages/client-generator/package.json b/packages/client-generator/package.json index e75fa20d0b..8ab58e2f69 100644 --- a/packages/client-generator/package.json +++ b/packages/client-generator/package.json @@ -16,6 +16,11 @@ "import": "./lib/generate.js", "default": "./lib/generate.js" }, + "./runtime-sources": { + "types": "./lib/runtime-sources.d.ts", + "import": "./lib/runtime-sources.js", + "default": "./lib/runtime-sources.js" + }, "./package.json": "./package.json" }, "engines": { @@ -28,7 +33,7 @@ }, "scripts": { "examples:regen": "node scripts/regenerate-examples.mjs", - "prepare": "node scripts/generate-runtime-sources.mjs", + "prepare": "node scripts/generate-runtime-sources.mjs && node scripts/generate-eject-assets.mjs", "typecheck:examples": "node scripts/typecheck-examples.mjs" }, "license": "MIT", @@ -64,6 +69,7 @@ "typescript": "6.0.2" }, "files": [ - "lib" + "lib", + "eject-assets" ] } diff --git a/packages/client-generator/runtime/go/go.mod b/packages/client-generator/runtime/go/go.mod new file mode 100644 index 0000000000..96f6641764 --- /dev/null +++ b/packages/client-generator/runtime/go/go.mod @@ -0,0 +1,3 @@ +module redocly.com/client-generator/go-runtime + +go 1.21 diff --git a/packages/client-generator/runtime/go/runtime.go b/packages/client-generator/runtime/go/runtime.go new file mode 100644 index 0000000000..5fcfba3627 --- /dev/null +++ b/packages/client-generator/runtime/go/runtime.go @@ -0,0 +1,859 @@ +// Package client — the embedded runtime for generated Go SDKs. Hand-authored +// once and stitched into every generated client (see +// scripts/generate-runtime-sources.mjs), semantically in lockstep with the +// TypeScript runtime: auth OR-alternatives, a retry loop with Retry-After and +// full-jitter backoff, per-attempt timeouts, idempotency keys, and middleware +// hooks. Standard library only — a generated Go SDK has zero dependencies. +package client + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "mime/multipart" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// APIError is returned for a non-2xx response, carrying the decoded error body. +type APIError struct { + URL string + Status int + StatusText string + Body any +} + +func (e *APIError) Error() string { + return fmt.Sprintf("request failed with status %d", e.Status) +} + +// TimeoutError is returned when a request attempt exceeds the configured +// timeout — carrying the context a log line needs. +type TimeoutError struct { + OperationID string + Timeout time.Duration + Attempt int +} + +func (e *TimeoutError) Error() string { + return fmt.Sprintf("request %q timed out after %s (attempt %d)", e.OperationID, e.Timeout, e.Attempt) +} + +// SecuritySpec mirrors the descriptor table's security entries. +type SecuritySpec struct { + Scheme string + Kind string // "bearer" | "basic" | "apiKey" + Name string // header/query/cookie name for apiKey + In string // "header" | "query" | "cookie" +} + +// Auth holds the client credentials; zero value = anonymous. +type Auth struct { + Bearer func() string + Basic *BasicAuth + APIKey map[string]func() string +} + +type BasicAuth struct { + Username string + Password string +} + +// RetryConfig mirrors the TypeScript runtime's retry policy knobs. +type RetryConfig struct { + Retries int + RetryDelay time.Duration // base; default 1s + RetryStrategy string // "" (exponential) | "fixed" + NoJitter bool + // RetryOn fully replaces the default predicate when set. + RetryOn func(attempt int, resp *http.Response, err error) bool +} + +// Middleware hooks run around every request (OnRequest before serialization order +// is N/A in Go — bodies are values; OnResponse runs in reverse registration order). +type Middleware struct { + OnRequest func(req *http.Request) + OnResponse func(resp *http.Response) +} + +// Date is an RFC 3339 full-date — a calendar date with no time component. Fields +// typed `date` under `dateType: Date` use it because encoding/json speaks only +// RFC 3339 date-time for time.Time, which a bare "2006-01-02" fails to satisfy. +type Date struct { + time.Time +} + +const dateLayout = "2006-01-02" + +// UnmarshalJSON parses a "2006-01-02" string; an empty string leaves the zero value. +func (d *Date) UnmarshalJSON(data []byte) error { + var raw string + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw == "" { + return nil + } + parsed, err := time.Parse(dateLayout, raw) + if err != nil { + return err + } + d.Time = parsed + return nil +} + +// MarshalJSON writes the date back without a time component. +func (d Date) MarshalJSON() ([]byte, error) { + return json.Marshal(d.Format(dateLayout)) +} + +// Config is the per-client configuration shared by every operation method. +type Config struct { + ServerURL string + HTTPClient *http.Client + Headers map[string]string + Timeout time.Duration + Retry RetryConfig + Middleware []Middleware + IdempotencyKey func() string + Auth Auth +} + +func resolveToken(provider func() string) string { + if provider == nil { + return "" + } + return provider() +} + +func schemeConfigured(spec SecuritySpec, auth Auth) bool { + switch spec.Kind { + case "apiKey": + _, ok := auth.APIKey[spec.Scheme] + return ok + case "bearer": + return auth.Bearer != nil + default: + return auth.Basic != nil + } +} + +// resolveAuth applies the first fully-configured OR-alternative; when none is, +// the first alternative's configured schemes are still sent (the server rejects +// the request — same behavior as the TypeScript runtime). +func resolveAuth(security [][]SecuritySpec, auth Auth) (map[string]string, url.Values) { + headers := map[string]string{} + query := url.Values{} + if len(security) == 0 { + return headers, query + } + alternative := security[0] + for _, candidate := range security { + all := true + for _, spec := range candidate { + if !schemeConfigured(spec, auth) { + all = false + break + } + } + if all { + alternative = candidate + break + } + } + var cookies []string + for _, spec := range alternative { + switch spec.Kind { + case "apiKey": + provider, ok := auth.APIKey[spec.Scheme] + if !ok { + continue + } + value := resolveToken(provider) + switch spec.In { + case "query": + query.Set(spec.Name, value) + case "cookie": + cookies = append(cookies, spec.Name+"="+url.QueryEscape(value)) + default: + headers[spec.Name] = value + } + case "bearer": + if auth.Bearer != nil { + headers["Authorization"] = "Bearer " + resolveToken(auth.Bearer) + } + default: + if auth.Basic != nil { + token := base64.StdEncoding.EncodeToString([]byte(auth.Basic.Username + ":" + auth.Basic.Password)) + headers["Authorization"] = "Basic " + token + } + } + } + if len(cookies) > 0 { + headers["Cookie"] = strings.Join(cookies, "; ") + } + return headers, query +} + +// buildURL substitutes {param} path placeholders with percent-encoded values. +func buildURL(serverURL, path string, pathParams map[string]string) string { + filled := path + for name, value := range pathParams { + filled = strings.ReplaceAll(filled, "{"+name+"}", url.PathEscape(value)) + } + return strings.TrimRight(serverURL, "/") + filled +} + +var transientStatus = map[int]bool{408: true, 429: true, 500: true, 502: true, 503: true, 504: true} + +func defaultRetryOn(method string, headers map[string]string, resp *http.Response, err error) bool { + safe := false + switch strings.ToUpper(method) { + case "GET", "HEAD", "PUT", "DELETE", "OPTIONS": + safe = true + } + if _, ok := headers["Idempotency-Key"]; ok { + safe = true + } + if !safe { + return false + } + if err != nil { + return true + } + return resp != nil && transientStatus[resp.StatusCode] +} + +func retryDelay(retry RetryConfig, attempt int, retryAfter string) time.Duration { + if retryAfter != "" { + if seconds, err := strconv.ParseFloat(retryAfter, 64); err == nil { + return time.Duration(seconds * float64(time.Second)) + } + } + base := retry.RetryDelay + if base == 0 { + base = time.Second + } + raw := base + if retry.RetryStrategy != "fixed" { + raw = base * time.Duration(1<<(attempt-1)) + } + if retry.NoJitter { + return raw + } + return time.Duration(rand.Int63n(int64(raw) + 1)) +} + +type requestSpec struct { + OperationID string + Method string + URL string + Headers map[string]string + Query url.Values + Body io.Reader + ContentType string + Timeout time.Duration + Retry *RetryConfig + IdempotencyKey string + // bodyBytes is retained so retries can replay the body. + bodyBytes []byte +} + +// send is the request core: header merge, idempotency keys, the retry loop +// (fresh timeout budget per attempt), and the middleware onion. +func send(ctx context.Context, config *Config, spec requestSpec) (*http.Response, error) { + retry := config.Retry + if spec.Retry != nil { + retry = *spec.Retry + } + timeout := config.Timeout + if spec.Timeout != 0 { + timeout = spec.Timeout + } + headers := map[string]string{} + for key, value := range config.Headers { + headers[key] = value + } + for key, value := range spec.Headers { + headers[key] = value + } + method := strings.ToUpper(spec.Method) + if (method == "POST" || method == "PATCH") && headers["Idempotency-Key"] == "" { + if spec.IdempotencyKey != "" { + headers["Idempotency-Key"] = spec.IdempotencyKey + } else if config.IdempotencyKey != nil { + headers["Idempotency-Key"] = config.IdempotencyKey() + } + } + httpClient := config.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + if spec.Body != nil { + payload, err := io.ReadAll(spec.Body) + if err != nil { + return nil, err + } + spec.bodyBytes = payload + } + fullURL := spec.URL + if len(spec.Query) > 0 { + separator := "?" + if strings.Contains(fullURL, "?") { + separator = "&" + } + fullURL += separator + spec.Query.Encode() + } + maxAttempts := 1 + retry.Retries + for attempt := 1; ; attempt++ { + attemptCtx := ctx + var cancel context.CancelFunc + if timeout > 0 { + attemptCtx, cancel = context.WithTimeout(ctx, timeout) + } + var bodyReader io.Reader + if spec.bodyBytes != nil { + bodyReader = bytes.NewReader(spec.bodyBytes) + } + req, err := http.NewRequestWithContext(attemptCtx, method, fullURL, bodyReader) + if err != nil { + if cancel != nil { + cancel() + } + return nil, err + } + for key, value := range headers { + req.Header.Set(key, value) + } + if spec.ContentType != "" && spec.bodyBytes != nil { + req.Header.Set("Content-Type", spec.ContentType) + } + for _, mw := range config.Middleware { + if mw.OnRequest != nil { + mw.OnRequest(req) + } + } + resp, err := httpClient.Do(req) + shouldRetry := retry.RetryOn + retryable := false + if shouldRetry != nil { + retryable = shouldRetry(attempt, resp, err) + } else { + retryable = defaultRetryOn(method, headers, resp, err) + } + if err != nil { + if cancel != nil { + cancel() + } + timedOut := errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil + if attempt < maxAttempts && retryable { + time.Sleep(retryDelay(retry, attempt, "")) + continue + } + if timedOut { + return nil, &TimeoutError{OperationID: spec.OperationID, Timeout: timeout, Attempt: attempt} + } + return nil, err + } + for i := len(config.Middleware) - 1; i >= 0; i-- { + if config.Middleware[i].OnResponse != nil { + config.Middleware[i].OnResponse(resp) + } + } + if resp.StatusCode >= 400 && attempt < maxAttempts && retryable { + after := resp.Header.Get("Retry-After") + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if cancel != nil { + cancel() + } + time.Sleep(retryDelay(retry, attempt, after)) + continue + } + // The response body outlives this call; tie the attempt context's lifetime to it. + if cancel != nil { + resp.Body = &cancelOnClose{ReadCloser: resp.Body, cancel: cancel} + } + return resp, nil + } +} + +type cancelOnClose struct { + io.ReadCloser + cancel context.CancelFunc +} + +func (c *cancelOnClose) Close() error { + c.cancel() + return c.ReadCloser.Close() +} + +// decodeJSON decodes a response body into target; a nil target drains and closes. +func decodeJSON(resp *http.Response, target any) error { + defer resp.Body.Close() + if target == nil { + _, err := io.Copy(io.Discard, resp.Body) + return err + } + return json.NewDecoder(resp.Body).Decode(target) +} + +// headerString returns the named response header, or nil when absent. +func headerString(header http.Header, name string) *string { + value := header.Get(name) + if value == "" { + return nil + } + return &value +} + +// headerInt64 parses the named header as an integer; nil when absent or unparsable. +func headerInt64(header http.Header, name string) *int64 { + raw := strings.TrimSpace(header.Get(name)) + if raw == "" { + return nil + } + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return nil + } + return &value +} + +// headerFloat64 parses the named header as a number; nil when absent or unparsable. +func headerFloat64(header http.Header, name string) *float64 { + raw := strings.TrimSpace(header.Get(name)) + if raw == "" { + return nil + } + value, err := strconv.ParseFloat(raw, 64) + if err != nil { + return nil + } + return &value +} + +// headerBool parses a `true`/`false` header; nil when absent or anything else. +func headerBool(header http.Header, name string) *bool { + raw := strings.ToLower(strings.TrimSpace(header.Get(name))) + if raw != "true" && raw != "false" { + return nil + } + value := raw == "true" + return &value +} + +// apiErrorFrom builds the structured error for a non-2xx response. +func apiErrorFrom(resp *http.Response, requestURL string) error { + defer resp.Body.Close() + var body any + data, _ := io.ReadAll(resp.Body) + if len(data) > 0 { + if err := json.Unmarshal(data, &body); err != nil { + body = string(data) + } + } + return &APIError{URL: requestURL, Status: resp.StatusCode, StatusText: resp.Status, Body: body} +} + +// ─── Pagination ─── + +// PaginationSpec mirrors the descriptor table's pagination entries. +type PaginationSpec struct { + Style string + Param string + NextCursor string + HasMore string + LimitParam string + Items string +} + +// resolvePointer walks an RFC 6901 JSON pointer over decoded JSON; nil on any miss. +func resolvePointer(data any, pointer string) any { + if pointer == "" { + return data + } + if !strings.HasPrefix(pointer, "/") { + return nil + } + current := data + for _, token := range strings.Split(pointer[1:], "/") { + key := strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~") + switch typed := current.(type) { + case map[string]any: + current = typed[key] + case []any: + index, err := strconv.Atoi(key) + if err != nil || index < 0 || index >= len(typed) { + return nil + } + current = typed[index] + default: + return nil + } + if current == nil { + return nil + } + } + return current +} + +// reencode converts decoded JSON (maps/slices) into a typed value via a JSON round-trip. +func reencode(raw any, target any) error { + data, err := json.Marshal(raw) + if err != nil { + return err + } + return json.Unmarshal(data, target) +} + +type pageCall func(params url.Values) (any, *http.Response, error) + +// iterPages yields raw page JSON per the pagination spec — the same stop +// conditions and infinite-loop guards as the TypeScript runtime. The returned +// function is a range-over-func iterator (Go 1.23+) and plainly callable before that. +func iterPages(call pageCall, spec PaginationSpec, base url.Values) func(yield func(any, error) bool) { + return func(yield func(any, error) bool) { + switch spec.Style { + case "cursor": + var cursor any + if values, ok := base[spec.Param]; ok && len(values) > 0 { + cursor = values[0] + } + for { + params := cloneValues(base) + if cursor != nil { + params.Set(spec.Param, fmt.Sprint(cursor)) + } + page, _, err := call(params) + if err != nil { + yield(nil, err) + return + } + if !yield(page, nil) { + return + } + if spec.HasMore != "" { + if more, ok := resolvePointer(page, spec.HasMore).(bool); ok && !more { + return + } + } + next := resolvePointer(page, spec.NextCursor) + if next == nil || next == "" { + return + } + switch next.(type) { + case string, float64: + default: + yield(nil, fmt.Errorf("pagination cursor at %s is not a string or number", spec.NextCursor)) + return + } + if cursor != nil && fmt.Sprint(next) == fmt.Sprint(cursor) { + yield(nil, errors.New("pagination did not advance: the operation returned the same cursor twice")) + return + } + cursor = next + } + case "link": + params := cloneValues(base) + previous := "" + for { + page, resp, err := call(params) + if err != nil { + yield(nil, err) + return + } + if !yield(page, nil) { + return + } + target := linkNext(resp.Header.Get("Link")) + if target == "" { + return + } + pageURL := "" + if resp.Request != nil && resp.Request.URL != nil { + pageURL = resp.Request.URL.String() + } + baseURL, err := url.Parse(pageURL) + if err != nil || pageURL == "" { + baseURL, _ = url.Parse("http://relative.invalid") + } + targetURL, err := baseURL.Parse(target) + if err != nil { + yield(nil, err) + return + } + next := targetURL.String() + if next == previous || next == pageURL { + yield(nil, errors.New(`pagination did not advance: the Link rel="next" target repeats`)) + return + } + previous = next + params = cloneValues(base) + for key, values := range targetURL.Query() { + for _, value := range values { + params.Add(key, value) + } + } + } + default: // offset / page + position := 0 + if spec.Style == "page" { + position = 1 + } + if values, ok := base[spec.Param]; ok && len(values) > 0 && values[0] != "" { + if parsed, err := strconv.Atoi(values[0]); err == nil { + position = parsed + } + } + previousItems := "" + for { + params := cloneValues(base) + params.Set(spec.Param, strconv.Itoa(position)) + page, _, err := call(params) + if err != nil { + yield(nil, err) + return + } + items, _ := resolvePointer(page, spec.Items).([]any) + serialized := "" + if items != nil { + serialized = fmt.Sprint(items) + if serialized == previousItems { + yield(nil, errors.New("pagination did not advance: the operation returned the same page twice")) + return + } + } + if !yield(page, nil) { + return + } + if len(items) == 0 { + return + } + previousItems = serialized + if spec.Style == "page" { + position++ + } else { + position += len(items) + } + } + } + } +} + +func cloneValues(values url.Values) url.Values { + out := url.Values{} + for key, entries := range values { + for _, entry := range entries { + out.Add(key, entry) + } + } + return out +} + +func linkNext(header string) string { + if header == "" { + return "" + } + for _, entry := range strings.Split(header, ",") { + parts := strings.Split(entry, ";") + if len(parts) < 2 { + continue + } + target := strings.TrimSpace(parts[0]) + if !strings.HasPrefix(target, "<") || !strings.HasSuffix(target, ">") { + continue + } + for _, param := range parts[1:] { + trimmed := strings.TrimSpace(param) + if strings.HasPrefix(trimmed, "rel=") { + rel := strings.Trim(strings.TrimPrefix(trimmed, "rel="), `"`) + for _, kind := range strings.Fields(rel) { + if kind == "next" { + return strings.Trim(target, "<>") + } + } + } + } + } + return "" +} + +// ─── Server-Sent Events ─── + +// ServerSentEvent is one decoded event; Data is the raw text (or parsed JSON +// for operations that declare a JSON event stream). +type ServerSentEvent struct { + Event string + Data any + ID string + Retry int +} + +func parseSSEFrame(raw string, jsonData bool) (ServerSentEvent, bool, error) { + event := ServerSentEvent{Retry: -1} + sawField := false + var dataLines []string + normalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n") + for _, line := range strings.Split(normalized, "\n") { + if line == "" || strings.HasPrefix(line, ":") { + continue + } + field, value, _ := strings.Cut(line, ":") + value = strings.TrimPrefix(value, " ") + sawField = true + switch field { + case "event": + event.Event = value + case "data": + dataLines = append(dataLines, value) + case "id": + event.ID = value + case "retry": + if parsed, err := strconv.Atoi(value); err == nil && parsed >= 0 && value != "" { + event.Retry = parsed + } + } + } + if !sawField { + return event, false, nil + } + text := strings.Join(dataLines, "\n") + event.Data = text + if jsonData && text != "" { + var parsed any + if err := json.Unmarshal([]byte(text), &parsed); err != nil { + return event, false, err + } + event.Data = parsed + } + return event, true, nil +} + +// iterSSE streams events, reconnecting on dropped connections with Last-Event-ID +// (a fresh open call = fresh auth); a 4xx/5xx or a bad JSON payload is definitive. +func iterSSE(open func(extraHeaders map[string]string) (*http.Response, error), jsonData bool) func(yield func(ServerSentEvent, error) bool) { + return func(yield func(ServerSentEvent, error) bool) { + lastEventID := "" + serverRetry := -1 + failures := 0 + for { + headers := map[string]string{"Accept": "text/event-stream"} + if lastEventID != "" { + headers["Last-Event-ID"] = lastEventID + } + resp, err := open(headers) + if err == nil && resp.StatusCode >= 400 { + yield(ServerSentEvent{}, apiErrorFrom(resp, "")) + return + } + if err == nil { + failures = 0 + buffer := "" + chunk := make([]byte, 4096) + clean := false + for { + n, readErr := resp.Body.Read(chunk) + buffer += string(chunk[:n]) + for { + frame, rest, found := strings.Cut(buffer, "\n\n") + if !found { + break + } + buffer = rest + event, ok, parseErr := parseSSEFrame(frame, jsonData) + if parseErr != nil { + resp.Body.Close() + yield(ServerSentEvent{}, parseErr) + return + } + if ok { + if event.ID != "" { + lastEventID = event.ID + } + if event.Retry >= 0 { + serverRetry = event.Retry + } + if !yield(event, nil) { + resp.Body.Close() + return + } + } + } + if readErr == io.EOF { + clean = true + break + } + if readErr != nil { + break + } + } + resp.Body.Close() + if clean { + if strings.TrimSpace(buffer) != "" { + if event, ok, parseErr := parseSSEFrame(buffer, jsonData); parseErr == nil && ok { + yield(event, nil) + } + } + return + } + } + failures++ + base := time.Second + if serverRetry >= 0 { + base = time.Duration(serverRetry) * time.Millisecond + } + delay := base * time.Duration(1<<(failures-1)) + if delay > 30*time.Second { + delay = 30 * time.Second + } + time.Sleep(time.Duration(rand.Int63n(int64(delay) + 1))) + } + } +} + +// ─── Multipart ─── + +// toMultipart splits a typed body into a multipart/form-data payload: []byte +// values upload as file parts, everything else as form fields (nested values +// JSON-encoded) — mirroring the TypeScript runtime's FormData serialization. +func toMultipart(body any) (string, io.Reader, error) { + var wire map[string]any + if err := reencode(body, &wire); err != nil { + return "", nil, err + } + buffer := &bytes.Buffer{} + writer := multipart.NewWriter(buffer) + for key, value := range wire { + switch typed := value.(type) { + case string: + if err := writer.WriteField(key, typed); err != nil { + return "", nil, err + } + case float64, bool: + if err := writer.WriteField(key, fmt.Sprint(typed)); err != nil { + return "", nil, err + } + default: + encoded, err := json.Marshal(typed) + if err != nil { + return "", nil, err + } + if err := writer.WriteField(key, string(encoded)); err != nil { + return "", nil, err + } + } + } + if err := writer.Close(); err != nil { + return "", nil, err + } + return writer.FormDataContentType(), buffer, nil +} diff --git a/packages/client-generator/runtime/php/runtime.php b/packages/client-generator/runtime/php/runtime.php new file mode 100644 index 0000000000..72b6c612ce --- /dev/null +++ b/packages/client-generator/runtime/php/runtime.php @@ -0,0 +1,504 @@ += 8.1, zero Composer dependencies; HTTP over the curl extension. +// The generated file re-declares the namespace; the embed strips this header. + +declare(strict_types=1); + +namespace RedoclyClientRuntime; + +/** A response with status >= 400, decoded body attached. */ +final class ApiError extends \RuntimeException +{ + public function __construct( + public readonly string $url, + public readonly int $status, + public readonly string $reason, + public readonly mixed $body, + ) { + parent::__construct("HTTP {$status} {$reason} for {$url}"); + } +} + +/** Every attempt timed out or failed to connect. */ +final class TimeoutError extends \RuntimeException +{ + public function __construct( + public readonly string $url, + public readonly ?float $timeout, + public readonly int $attempts, + ) { + $seconds = $timeout === null ? 'the configured timeout' : "{$timeout}s"; + parent::__construct("Request to {$url} timed out after {$seconds} ({$attempts} attempt(s))"); + } +} + +/** One parsed `text/event-stream` frame. */ +/** A `WithHeaders()` result: the decoded body plus coerced declared headers. */ +final class Envelope +{ + public function __construct( + public readonly mixed $data, + public readonly array $headers, + public readonly int $status, + ) { + } +} + +/** Coerce declared response headers per `[name, key, type]` specs; absent/unparsable omitted. */ +function readEnvelopeHeaders(array $response, array $specs): array +{ + $headers = []; + foreach ($specs as [$name, $key, $type]) { + $raw = $response['headers'][$name] ?? null; + if ($raw === null) { + continue; + } + if ($type === 'integer' || $type === 'number') { + if (is_numeric($raw)) { + $headers[$key] = $type === 'integer' ? (int) $raw : (float) $raw; + } + } elseif ($type === 'boolean') { + $lower = strtolower(trim($raw)); + if ($lower === 'true' || $lower === 'false') { + $headers[$key] = $lower === 'true'; + } + } else { + $headers[$key] = $raw; + } + } + return $headers; +} + +final class ServerSentEvent +{ + public function __construct( + public readonly string $event, + public readonly mixed $data, + public readonly ?string $id = null, + public readonly ?int $retry = null, + ) { + } +} + +/** + * Per-instance configuration. + * `auth`: `['bearer' => string|callable, 'basic' => ['username' => ..., 'password' => ...], 'apiKey' => [scheme => string|callable]]`. + * `retry`: `['attempts' => int, 'delay' => float, 'strategy' => 'exponential'|'fixed', 'retryOn' => callable]`. + * `middleware`: callables `fn(array $request, callable $next): array` around each attempt. + */ +final class Config +{ + public function __construct( + public string $serverUrl = '', + public array $auth = [], + public ?float $timeout = null, + public array $retry = [], + public array $middleware = [], + public string $clientHeader = 'redocly-client-generator', + ) { + } +} + +/** Resolve a literal-or-callable credential to its string value. */ +function resolveToken(mixed $provider): string +{ + return is_callable($provider) ? (string) $provider() : (string) $provider; +} + +/** + * Apply the first fully-configured security alternative. `$security` is an OR-list + * of AND-sets of specs: `['kind' => 'bearer'|'basic'|'apiKey', 'scheme' => ..., 'name' => ?, 'in' => ?]`. + * Returns `[headers, query, cookies]`. + */ +function resolveAuth(array $security, array $auth): array +{ + foreach ($security as $andSet) { + $headers = []; + $query = []; + $cookies = []; + $satisfied = true; + foreach ($andSet as $spec) { + if ($spec['kind'] === 'bearer' && isset($auth['bearer'])) { + $headers['Authorization'] = 'Bearer ' . resolveToken($auth['bearer']); + } elseif ($spec['kind'] === 'basic' && isset($auth['basic'])) { + $headers['Authorization'] = + 'Basic ' . base64_encode($auth['basic']['username'] . ':' . $auth['basic']['password']); + } elseif ($spec['kind'] === 'apiKey' && isset($auth['apiKey'][$spec['scheme']])) { + $value = resolveToken($auth['apiKey'][$spec['scheme']]); + if ($spec['in'] === 'query') { + $query[$spec['name']] = $value; + } elseif ($spec['in'] === 'cookie') { + $cookies[] = $spec['name'] . '=' . rawurlencode($value); + } else { + $headers[$spec['name']] = $value; + } + } else { + $satisfied = false; + break; + } + } + if ($satisfied) { + return [$headers, $query, $cookies]; + } + } + return [[], [], []]; +} + +/** Substitute `{param}` templates with encoded values and prefix the server URL. */ +function buildUrl(string $serverUrl, string $path, array $pathParams): string +{ + foreach ($pathParams as $name => $value) { + $path = str_replace('{' . $name . '}', rawurlencode((string) $value), $path); + } + return rtrim($serverUrl, '/') . $path; +} + +/** The default retry predicate: 5xx, 429, and transport timeouts/connect failures. */ +function defaultRetryOn(array $context): bool +{ + if (($context['timedOut'] ?? false) === true) { + return true; + } + $status = $context['status'] ?? 0; + return $status >= 500 || $status === 429; +} + +/** Delay before the next attempt: `Retry-After` wins; otherwise jittered (fixed|exponential) backoff. */ +function retryDelay(int $attempt, array $retry, ?string $retryAfter): float +{ + if ($retryAfter !== null && ctype_digit($retryAfter)) { + return (float) $retryAfter; + } + $base = (float) ($retry['delay'] ?? 1.0); + $strategy = $retry['strategy'] ?? 'exponential'; + $delay = $strategy === 'fixed' ? $base : $base * (2 ** ($attempt - 1)); + return $delay * (0.5 + mt_rand() / mt_getrandmax() / 2); +} + +/** Append query params in form style: list values repeat the key (`tag=a&tag=b`). */ +function appendQuery(string $url, array $query): string +{ + $pairs = []; + foreach ($query as $name => $value) { + foreach (is_array($value) ? $value : [$value] as $single) { + $encoded = is_bool($single) ? ($single ? 'true' : 'false') : (string) $single; + $pairs[] = rawurlencode($name) . '=' . rawurlencode($encoded); + } + } + if ($pairs === []) { + return $url; + } + return $url . (str_contains($url, '?') ? '&' : '?') . implode('&', $pairs); +} + +/** One raw curl exchange. Returns `['status', 'reason', 'headers', 'body', 'url', 'timedOut']`. */ +function rawSend(Config $config, array $request): array +{ + $url = appendQuery($request['url'], $request['query'] ?? []); + $handle = curl_init($url); + $headerLines = []; + foreach ($request['headers'] ?? [] as $name => $value) { + $headerLines[] = $name . ': ' . $value; + } + $responseHeaders = []; + curl_setopt_array($handle, [ + CURLOPT_CUSTOMREQUEST => $request['method'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headerLines, + CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$responseHeaders): int { + $parts = explode(':', $line, 2); + if (count($parts) === 2) { + $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]); + } + return strlen($line); + }, + ]); + if (($request['body'] ?? null) !== null) { + curl_setopt($handle, CURLOPT_POSTFIELDS, $request['body']); + } + if ($config->timeout !== null) { + curl_setopt($handle, CURLOPT_TIMEOUT_MS, (int) round($config->timeout * 1000)); + } + $body = curl_exec($handle); + $errno = curl_errno($handle); + $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); + $effectiveUrl = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL); + if ($errno !== 0) { + $timedOut = $errno === CURLE_OPERATION_TIMEDOUT || $errno === CURLE_COULDNT_CONNECT; + return [ + 'status' => 0, + 'reason' => curl_strerror($errno) ?? 'transport error', + 'headers' => [], + 'body' => '', + 'url' => $effectiveUrl, + 'timedOut' => $timedOut, + ]; + } + return [ + 'status' => $status, + 'reason' => '', + 'headers' => $responseHeaders, + 'body' => is_string($body) ? $body : '', + 'url' => $effectiveUrl, + 'timedOut' => false, + ]; +} + +/** + * Send with retries and middleware. `$request` carries `operationId`, `method`, `url`, + * `headers`, `query`, and optional `body`/`contentType`/`idempotencyKey`. + * Returns the raw response array; callers map status >= 400 to `ApiError`. + */ +function send(Config $config, array $request): array +{ + $headers = $request['headers'] ?? []; + $headers['X-Redocly-Client'] = $config->clientHeader; + if (($request['contentType'] ?? null) !== null) { + $headers['Content-Type'] = $request['contentType']; + } + if (($request['idempotencyKey'] ?? null) !== null) { + $headers['Idempotency-Key'] = $request['idempotencyKey']; + } + $request['headers'] = $headers; + + $handler = fn (array $req): array => rawSend($config, $req); + foreach (array_reverse($config->middleware) as $middleware) { + $next = $handler; + $handler = fn (array $req): array => $middleware($req, $next); + } + + $attempts = max(1, (int) ($config->retry['attempts'] ?? 3)); + $retryOn = $config->retry['retryOn'] ?? __NAMESPACE__ . '\\defaultRetryOn'; + $response = null; + for ($attempt = 1; $attempt <= $attempts; $attempt++) { + $response = $handler($request); + $context = [ + 'status' => $response['status'], + 'timedOut' => $response['timedOut'], + 'attempt' => $attempt, + 'operationId' => $request['operationId'] ?? '', + ]; + if ($attempt === $attempts || !$retryOn($context)) { + break; + } + $seconds = retryDelay($attempt, $config->retry, $response['headers']['retry-after'] ?? null); + usleep((int) round($seconds * 1_000_000)); + } + if ($response['timedOut']) { + throw new TimeoutError($response['url'], $config->timeout, $attempts); + } + if ($response['status'] === 0) { + throw new \RuntimeException("Request to {$response['url']} failed: {$response['reason']}"); + } + return $response; +} + +/** Decoded JSON body (assoc arrays), or null for empty bodies. */ +function decodeJson(array $response): mixed +{ + if ($response['body'] === '') { + return null; + } + return json_decode($response['body'], true); +} + +/** `ApiError` from a non-2xx response. */ +function apiErrorFrom(array $response): ApiError +{ + return new ApiError($response['url'], $response['status'], $response['reason'], decodeJson($response)); +} + +/** Walk an RFC 6901 JSON pointer over decoded JSON; null on any miss. */ +function resolvePointer(mixed $data, string $pointer): mixed +{ + if ($pointer === '') { + return $data; + } + foreach (explode('/', substr($pointer, 1)) as $token) { + $key = str_replace(['~1', '~0'], ['/', '~'], $token); + if (!is_array($data) || !array_key_exists($key, $data)) { + return null; + } + $data = $data[$key]; + } + return $data; +} + +/** The `rel="next"` target of a `Link` header, or null. */ +function linkNext(?string $header): ?string +{ + if ($header === null) { + return null; + } + foreach (explode(',', $header) as $part) { + if (preg_match('/<([^>]+)>\s*;[^,]*rel="?next"?/', trim($part), $match) === 1) { + return $match[1]; + } + } + return null; +} + +/** + * Auto-pagination: `$call(array $params): [mixed rawPage, array $response]`, `$spec` is the + * normalized rule (`style`, `param`, `nextCursor`, `hasMore`, `items`), `$base` the caller's + * query params. Yields raw decoded pages; generated wrappers hydrate them into models. + */ +function iterPages(callable $call, array $spec, array $base): \Generator +{ + $params = $base; + $style = $spec['style']; + $seenCursors = []; + $seenLinks = []; + $offset = null; + $page = null; + while (true) { + [$raw, $response] = $call($params); + yield $raw; + if ($style === 'cursor') { + $next = resolvePointer($raw, $spec['nextCursor'] ?? ''); + if (isset($spec['hasMore']) && resolvePointer($raw, $spec['hasMore']) !== true) { + return; + } + if (!is_string($next) || $next === '' || isset($seenCursors[$next])) { + return; + } + $seenCursors[$next] = true; + $params[$spec['param']] = $next; + } elseif ($style === 'link') { + $target = linkNext($response['headers']['link'] ?? null); + if ($target === null || isset($seenLinks[$target])) { + return; + } + $seenLinks[$target] = true; + $parsed = parse_url($target); + $linkParams = []; + parse_str($parsed['query'] ?? '', $linkParams); + $params = array_merge($params, $linkParams); + } else { + $items = resolvePointer($raw, $spec['items'] ?? ''); + $count = is_array($items) ? count($items) : 0; + if ($count === 0) { + return; + } + if ($style === 'offset') { + $offset = ($offset ?? (int) ($base[$spec['param']] ?? 0)) + $count; + $params[$spec['param']] = $offset; + } else { + $page = ($page ?? (int) ($base[$spec['param']] ?? 1)) + 1; + $params[$spec['param']] = $page; + } + } + } +} + +/** Parse one SSE frame; returns `[?ServerSentEvent, ?string lastEventId, ?int retryMs]`. */ +function parseSseFrame(string $frame, bool $jsonData): array +{ + $event = 'message'; + $dataLines = []; + $id = null; + $retry = null; + foreach (explode("\n", str_replace("\r\n", "\n", $frame)) as $line) { + if ($line === '' || str_starts_with($line, ':')) { + continue; + } + $colon = strpos($line, ':'); + $field = $colon === false ? $line : substr($line, 0, $colon); + $value = $colon === false ? '' : ltrim(substr($line, $colon + 1), ' '); + if ($field === 'event') { + $event = $value; + } elseif ($field === 'data') { + $dataLines[] = $value; + } elseif ($field === 'id') { + $id = $value; + } elseif ($field === 'retry' && ctype_digit($value)) { + $retry = (int) $value; + } + } + if ($dataLines === [] && $id === null && $retry === null) { + return [null, null, $retry]; + } + $data = implode("\n", $dataLines); + $decoded = $jsonData && $data !== '' ? json_decode($data, true) : $data; + return [new ServerSentEvent($event, $decoded, $id, $retry), $id, $retry]; +} + +/** + * Stream server-sent events. `$open(array $extraHeaders): \CurlHandle` returns a configured + * (not yet executed) handle; this pump drives it with curl_multi, yields parsed frames, and + * reconnects with `Last-Event-ID` on transient failures (4xx is definitive; backoff <= 30s). + */ +function iterSse(callable $open, bool $jsonData): \Generator +{ + $lastEventId = null; + $retryMs = 3000; + while (true) { + $extra = ['Accept' => 'text/event-stream']; + if ($lastEventId !== null) { + $extra['Last-Event-ID'] = $lastEventId; + } + $handle = $open($extra); + $buffer = ''; + curl_setopt($handle, CURLOPT_WRITEFUNCTION, function ($ch, string $chunk) use (&$buffer): int { + $buffer .= $chunk; + return strlen($chunk); + }); + $multi = curl_multi_init(); + curl_multi_add_handle($multi, $handle); + do { + curl_multi_exec($multi, $running); + if ($running > 0) { + curl_multi_select($multi, 0.1); + } + while (($split = strpos($buffer, "\n\n")) !== false || ($split = strpos($buffer, "\r\n\r\n")) !== false) { + $frameLength = $buffer[$split] === "\r" ? 4 : 2; + $frame = substr($buffer, 0, $split); + $buffer = substr($buffer, $split + $frameLength); + [$event, $id, $retry] = parseSseFrame($frame, $jsonData); + if ($id !== null) { + $lastEventId = $id; + } + if ($retry !== null) { + $retryMs = min($retry, 30000); + } + if ($event !== null) { + yield $event; + } + } + } while ($running > 0); + $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); + $url = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL); + curl_multi_remove_handle($multi, $handle); + curl_multi_close($multi); + if ($status >= 400 && $status < 500) { + throw new ApiError($url, $status, '', $buffer); + } + // A clean 200 end-of-stream is done; anything else reconnects with Last-Event-ID. + if ($status === 200) { + return; + } + usleep($retryMs * 1000); + } +} + +/** Encode an assoc body as `multipart/form-data`; nested values are JSON parts. Returns `[contentType, body]`. */ +function toMultipart(array $body): array +{ + $boundary = 'redocly-' . bin2hex(random_bytes(12)); + $parts = ''; + foreach ($body as $name => $value) { + $parts .= "--{$boundary}\r\n"; + if (is_array($value)) { + $parts .= "Content-Disposition: form-data; name=\"{$name}\"\r\n"; + $parts .= "Content-Type: application/json\r\n\r\n"; + $parts .= json_encode($value) . "\r\n"; + } else { + $parts .= "Content-Disposition: form-data; name=\"{$name}\"\r\n\r\n"; + $parts .= (is_bool($value) ? ($value ? 'true' : 'false') : (string) $value) . "\r\n"; + } + } + $parts .= "--{$boundary}--\r\n"; + return ['multipart/form-data; boundary=' . $boundary, $parts]; +} diff --git a/packages/client-generator/runtime/python/_auth.py b/packages/client-generator/runtime/python/_auth.py new file mode 100644 index 0000000000..c6bed0347e --- /dev/null +++ b/packages/client-generator/runtime/python/_auth.py @@ -0,0 +1,74 @@ +# Auth resolution for generated Python clients — mirror of the TypeScript +# runtime's auth.ts: the first OR-alternative whose schemes are all configured +# is applied, so "bearer OR apiKey" works with either credential and never +# sends both. Cookie-borne api keys fold into a single Cookie header. +from __future__ import annotations + +import base64 +from typing import Any, Callable, Dict, List, Tuple, Union +from urllib.parse import quote + +TokenProvider = Union[str, Callable[[], str]] + + +def _api_keys(auth: Dict[str, Any]) -> Dict[str, Any]: + """The apiKey credentials. `apiKey` is the documented key (it matches the scheme + kind and the other language SDKs); `api_key` is accepted too, so a snake_case + config keeps working.""" + return {**(auth.get("api_key") or {}), **(auth.get("apiKey") or {})} + +def _resolve_token(provider: TokenProvider) -> str: + return provider() if callable(provider) else provider + + +def _is_configured(scheme: Dict[str, Any], auth: Dict[str, Any]) -> bool: + kind = scheme["kind"] + if kind == "apiKey": + return scheme["scheme"] in _api_keys(auth) + if kind == "bearer": + return auth.get("bearer") is not None + return auth.get("basic") is not None + + +def resolve_auth( + security: List[List[Dict[str, Any]]], auth: Dict[str, Any] +) -> Tuple[Dict[str, str], Dict[str, str]]: + """Build (headers, query) for one operation's security OR-alternatives from + the client credentials. When no alternative is fully configured, the first + alternative's configured schemes are still sent (the server rejects the + request — same behavior as the TypeScript runtime).""" + alternative = next( + (schemes for schemes in security if all(_is_configured(s, auth) for s in schemes)), + security[0] if security else [], + ) + headers: Dict[str, str] = {} + query: Dict[str, str] = {} + cookies: List[str] = [] + for scheme in alternative: + kind = scheme["kind"] + if kind == "apiKey": + provider = _api_keys(auth).get(scheme["scheme"]) + if provider is None: + continue + value = _resolve_token(provider) + location = scheme.get("in", "header") + if location == "header": + headers[scheme["name"]] = value + elif location == "query": + query[scheme["name"]] = value + else: + # Reserved characters (`;`, `=`, space) must not break Cookie syntax. + cookies.append(f"{scheme['name']}={quote(value, safe='')}") + elif kind == "bearer": + provider = auth.get("bearer") + if provider is not None: + headers["Authorization"] = f"Bearer {_resolve_token(provider)}" + else: + basic = auth.get("basic") + if basic is not None: + username, password = basic["username"], basic["password"] + token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii") + headers["Authorization"] = f"Basic {token}" + if cookies: + headers["Cookie"] = "; ".join(cookies) + return headers, query diff --git a/packages/client-generator/runtime/python/_decode.py b/packages/client-generator/runtime/python/_decode.py new file mode 100644 index 0000000000..0d17ddc971 --- /dev/null +++ b/packages/client-generator/runtime/python/_decode.py @@ -0,0 +1,117 @@ +# Reflective JSON <-> model conversion for generated Python clients. Models are +# plain dataclasses by default, or pydantic BaseModels under `models: pydantic`; +# one decoder serves both. For a dataclass it hydrates parsed JSON reflectively, +# honoring each class's `_field_map` (python name -> wire name) and the typing +# constructs the generator emits: Optional/Union, List, Dict, Enum, Literal, Any. +# For a pydantic model it defers to pydantic, which already knows the aliases. +# encode() mirrors whichever it was given back to wire shape. +from __future__ import annotations + +import dataclasses +import typing +from datetime import date, datetime +from enum import Enum +from typing import Any, Dict, Tuple, get_args, get_origin, get_type_hints + +# Discriminated unions: resolved Union annotation -> (wire property, {value: class}). +# The generated module registers its unions here; decode() dispatches through it +# before falling back to trying members in order. +DISCRIMINATORS: Dict[Any, Tuple[str, Dict[str, Any]]] = {} + + +def decode(type_: Any, data: Any): + """Best-effort hydration: wire data -> the annotated Python shape. Unknown or + mismatched shapes pass through unchanged (the server is the source of truth).""" + if data is None or type_ is Any or type_ is None: + return data + # `Annotated[Union[...], Field(discriminator=...)]`: pydantic reads that annotation on a + # model's own field, so here only the union underneath matters. + if hasattr(type_, "__metadata__"): + type_ = get_args(type_)[0] + origin = get_origin(type_) + if origin is typing.Union: + discriminator = DISCRIMINATORS.get(type_) + if discriminator is not None and isinstance(data, dict): + wire_property, mapping = discriminator + target = mapping.get(data.get(wire_property)) + if target is not None: + try: + return decode(target, data) + except (TypeError, ValueError, KeyError): + pass + for member in get_args(type_): + if member is type(None): + continue + try: + return decode(member, data) + except (TypeError, ValueError, KeyError): + continue + return data + if origin is list: + (item_type,) = get_args(type_) or (Any,) + return [decode(item_type, item) for item in data] + if origin is dict: + args = get_args(type_) + value_type = args[1] if len(args) == 2 else Any + return {key: decode(value_type, value) for key, value in data.items()} + if origin is typing.Literal: + return data + if isinstance(type_, type) and issubclass(type_, Enum): + return type_(data) + # `dateType: Date` annotates date/date-time fields as datetime objects; a value that + # doesn't parse passes through unchanged (the server is the source of truth). + if type_ is datetime or type_ is date: + if not isinstance(data, str): + return data + try: + # `datetime` accepts a bare date too; `date` rejects a timestamp, so trim it. + return ( + datetime.fromisoformat(data) + if type_ is datetime + else date.fromisoformat(data[:10]) + ) + except ValueError: + return data + # A pydantic model validates itself, aliases included. `ValidationError` + # subclasses `ValueError`, so union member probing above still works. + if isinstance(type_, type) and hasattr(type_, "model_validate"): + return type_.model_validate(data) + if dataclasses.is_dataclass(type_): + hints = get_type_hints(type_) + field_map = getattr(type_, "_field_map", {}) + kwargs = {} + for field in dataclasses.fields(type_): + wire = field_map.get(field.name, field.name) + if isinstance(data, dict) and wire in data: + kwargs[field.name] = decode(hints.get(field.name, Any), data[wire]) + return type_(**kwargs) + return data + + +def encode(value: Any): + """Python shape -> wire (JSON) shape; inverse of decode for request bodies.""" + # `mode="json"` resolves datetimes and enums the same way the branches below do, + # and `exclude_none` matches the dataclass path: an unset optional is not sent. + if hasattr(value, "model_dump") and not isinstance(value, type): + return value.model_dump(by_alias=True, exclude_none=True, mode="json") + if dataclasses.is_dataclass(value) and not isinstance(value, type): + field_map = getattr(type(value), "_field_map", {}) + out = {} + for field in dataclasses.fields(value): + item = getattr(value, field.name) + if item is None: + continue + out[field_map.get(field.name, field.name)] = encode(item) + return out + if isinstance(value, Enum): + return value.value + # A date-only value must not gain a time component on the way out. + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, date): + return value.isoformat() + if isinstance(value, list): + return [encode(item) for item in value] + if isinstance(value, dict): + return {key: encode(item) for key, item in value.items()} + return value diff --git a/packages/client-generator/runtime/python/_errors.py b/packages/client-generator/runtime/python/_errors.py new file mode 100644 index 0000000000..ce51709206 --- /dev/null +++ b/packages/client-generator/runtime/python/_errors.py @@ -0,0 +1,48 @@ +# Runtime errors and the result-mode envelope for generated Python clients. +# Hand-authored once, embedded into every generated client (see +# scripts/generate-runtime-sources.mjs) — mirror of the TypeScript runtime's +# errors.ts, kept semantically in lockstep. +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Generic, Optional, TypeVar + +T = TypeVar("T") +E = TypeVar("E") + + +class ApiError(Exception): + """Raised (throw mode) for a non-2xx response, carrying the decoded error body.""" + + def __init__(self, url: str, status: int, status_text: str, body: Any) -> None: + super().__init__(f"Request failed with status {status}") + self.url = url + self.status = status + self.status_text = status_text + self.body = body + + +class ApiTimeoutError(Exception): + """Raised when a request attempt exceeds the configured timeout — carries the + context a log line needs (which operation, what budget, which attempt).""" + + def __init__(self, operation_id: str, timeout: float, attempt: int) -> None: + super().__init__( + f'Request "{operation_id}" timed out after {timeout} s (attempt {attempt})' + ) + self.operation_id = operation_id + self.timeout = timeout + self.attempt = attempt + + +@dataclass +class Result(Generic[T, E]): + """Result-mode return shape: exactly one of `data`/`error` is set.""" + + data: Optional[T] + error: Optional[E] + response: Any # httpx.Response + + @property + def ok(self) -> bool: + return self.error is None diff --git a/packages/client-generator/runtime/python/_multipart.py b/packages/client-generator/runtime/python/_multipart.py new file mode 100644 index 0000000000..e1dedb8cf0 --- /dev/null +++ b/packages/client-generator/runtime/python/_multipart.py @@ -0,0 +1,24 @@ +# Multipart bodies for generated Python clients — a typed dict/dataclass body is +# split into httpx's (data, files): bytes and file-like values upload as parts, +# everything else is form data (nested values JSON-encoded, mirroring the +# TypeScript runtime's FormData serialization). +from __future__ import annotations + +import json +from typing import Any, Dict, Tuple + +from ._decode import encode + + +def to_multipart(body: Any) -> Tuple[Dict[str, Any], Dict[str, Any]]: + wire = encode(body) + data: Dict[str, Any] = {} + files: Dict[str, Any] = {} + for key, value in (wire or {}).items(): + if isinstance(value, (bytes, bytearray)) or hasattr(value, "read"): + files[key] = value + elif isinstance(value, (dict, list)): + data[key] = json.dumps(value) + else: + data[key] = value + return data, files diff --git a/packages/client-generator/runtime/python/_paginate.py b/packages/client-generator/runtime/python/_paginate.py new file mode 100644 index 0000000000..ca0d3cf341 --- /dev/null +++ b/packages/client-generator/runtime/python/_paginate.py @@ -0,0 +1,206 @@ +# Auto-pagination iterators for generated Python clients — the TypeScript +# runtime's paginate.ts semantics ported: cursor (next-cursor pointer, optional +# has-more flag, repeated-cursor guard), offset/page (advance by count/one, +# repeated-page guard, null start treated as absent), and link (RFC 8288 +# `Link: rel="next"` following with relative resolution and a loop guard). +from __future__ import annotations + +import re +from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Iterator, Optional, Tuple +from urllib.parse import parse_qsl, urljoin, urlparse + +# call(params) -> (parsed_json, httpx.Response) +PageCall = Callable[[Dict[str, Any]], Tuple[Any, Any]] + + +def resolve_pointer(data: Any, pointer: str) -> Any: + """RFC 6901 JSON pointer over parsed JSON; None on any miss.""" + if pointer == "": + return data + if not pointer.startswith("/"): + return None + current = data + for token in pointer[1:].split("/"): + key = token.replace("~1", "/").replace("~0", "~") + if isinstance(current, dict): + current = current.get(key) + elif isinstance(current, list) and key.isdigit(): + index = int(key) + current = current[index] if index < len(current) else None + else: + return None + if current is None: + return None + return current + + +def iter_pages(call: PageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> Iterator[Any]: + """Yield raw page JSON per the pagination spec; every page is yielded before + the stop condition is evaluated, so the last page always arrives.""" + style = spec["style"] + base = dict(params or {}) + if style == "cursor": + cursor = base.get(spec["param"]) + while True: + page_params = dict(base) + if cursor is not None: + page_params[spec["param"]] = cursor + page, _response = call(page_params) + yield page + if spec.get("has_more") is not None and resolve_pointer(page, spec["has_more"]) is False: + return + nxt = resolve_pointer(page, spec.get("next_cursor", "")) + if nxt is None or nxt == "": + return + if not isinstance(nxt, (str, int, float)): + raise ValueError(f"Pagination cursor at {spec['next_cursor']} is not a string or number") + if nxt == cursor: + raise ValueError("Pagination did not advance: the operation returned the same cursor twice") + cursor = nxt + elif style == "link": + yield from _iter_pages_by_link(call, base) + else: # offset / page + start = base.get(spec["param"]) + fallback = 1 if style == "page" else 0 + try: + position = fallback if start in (None, "") else int(start) + except (TypeError, ValueError): + position = fallback + previous_items = None + while True: + page, _response = call({**base, spec["param"]: position}) + items = resolve_pointer(page, spec.get("items", "")) + serialized = repr(items) if isinstance(items, list) else None + if serialized is not None and serialized == previous_items: + raise ValueError("Pagination did not advance: the operation returned the same page twice") + yield page + if not isinstance(items, list) or len(items) == 0: + return + previous_items = serialized + position += 1 if style == "page" else len(items) + + +def _link_next(header: Optional[str]) -> Optional[str]: + if not header: + return None + for entry in re.split(r",\s*(?=<)", header): + match = re.match(r"^\s*<([^>]*)>(.*)$", entry) + if not match: + continue + rel = re.search(r';\s*rel\s*=\s*"?([^";]+)"?', match.group(2), re.IGNORECASE) + if rel and "next" in rel.group(1).split(): + return match.group(1) + return None + + +def _iter_pages_by_link(call: PageCall, base: Dict[str, Any]) -> Iterator[Any]: + params = dict(base) + previous = None + while True: + page, response = call(params) + yield page + target = _link_next(response.headers.get("link")) + if target is None: + return + page_url = str(response.request.url) if response.request is not None else "" + nxt = urljoin(page_url or "http://relative.invalid", target) + if nxt in (previous, page_url): + raise ValueError('Pagination did not advance: the Link rel="next" target repeats') + previous = nxt + link_params: Dict[str, Any] = {} + for key, value in parse_qsl(urlparse(nxt).query): + if key in link_params: + existing = link_params[key] + link_params[key] = [*existing, value] if isinstance(existing, list) else [existing, value] + else: + link_params[key] = value + params = {**base, **link_params} + + +def iter_items(call: PageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> Iterator[Any]: + """Each page's `items` pointer, flattened.""" + for page in iter_pages(call, spec, params): + items = resolve_pointer(page, spec.get("items", "")) + if isinstance(items, list): + yield from items + + +# call(params) -> awaitable of (parsed_json, httpx.Response) +AsyncPageCall = Callable[[Dict[str, Any]], Awaitable[Tuple[Any, Any]]] + + +async def aiter_pages( + call: AsyncPageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None +) -> AsyncIterator[Any]: + """Async mirror of iter_pages — same stop conditions and guards.""" + style = spec["style"] + base = dict(params or {}) + if style == "cursor": + cursor = base.get(spec["param"]) + while True: + page_params = dict(base) + if cursor is not None: + page_params[spec["param"]] = cursor + page, _response = await call(page_params) + yield page + if spec.get("has_more") is not None and resolve_pointer(page, spec["has_more"]) is False: + return + nxt = resolve_pointer(page, spec.get("next_cursor", "")) + if nxt is None or nxt == "": + return + if not isinstance(nxt, (str, int, float)): + raise ValueError(f"Pagination cursor at {spec['next_cursor']} is not a string or number") + if nxt == cursor: + raise ValueError("Pagination did not advance: the operation returned the same cursor twice") + cursor = nxt + elif style == "link": + previous = None + link_params: Dict[str, Any] = dict(base) + while True: + page, response = await call(link_params) + yield page + target = _link_next(response.headers.get("link")) + if target is None: + return + page_url = str(response.request.url) if response.request is not None else "" + nxt = urljoin(page_url or "http://relative.invalid", target) + if nxt in (previous, page_url): + raise ValueError('Pagination did not advance: the Link rel="next" target repeats') + previous = nxt + merged: Dict[str, Any] = {} + for key, value in parse_qsl(urlparse(nxt).query): + if key in merged: + existing = merged[key] + merged[key] = [*existing, value] if isinstance(existing, list) else [existing, value] + else: + merged[key] = value + link_params = {**base, **merged} + else: + start = base.get(spec["param"]) + fallback = 1 if style == "page" else 0 + try: + position = fallback if start in (None, "") else int(start) + except (TypeError, ValueError): + position = fallback + previous_items = None + while True: + page, _response = await call({**base, spec["param"]: position}) + items = resolve_pointer(page, spec.get("items", "")) + serialized = repr(items) if isinstance(items, list) else None + if serialized is not None and serialized == previous_items: + raise ValueError("Pagination did not advance: the operation returned the same page twice") + yield page + if not isinstance(items, list) or len(items) == 0: + return + previous_items = serialized + position += 1 if style == "page" else len(items) + + +async def aiter_items( + call: AsyncPageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None +) -> AsyncIterator[Any]: + async for page in aiter_pages(call, spec, params): + items = resolve_pointer(page, spec.get("items", "")) + if isinstance(items, list): + for item in items: + yield item diff --git a/packages/client-generator/runtime/python/_send.py b/packages/client-generator/runtime/python/_send.py new file mode 100644 index 0000000000..ffabcbbccf --- /dev/null +++ b/packages/client-generator/runtime/python/_send.py @@ -0,0 +1,255 @@ +# The request core for generated Python clients — mirror of the TypeScript +# runtime's send.ts: default + config + per-call headers, on_request middleware +# BEFORE serialization (mutations are sent), the retry loop (idempotent-methods +# default, Idempotency-Key opt-in makes POST/PATCH safe, Retry-After honored, +# exponential backoff with full jitter, a fresh timeout budget per attempt), and +# the reverse on_response onion. +from __future__ import annotations + +import asyncio +import random +import time +import uuid +from dataclasses import dataclass +from typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar + +import httpx + +from ._errors import ApiTimeoutError + +T = TypeVar("T") + + +@dataclass +class Envelope(Generic[T]): + """A *_with_headers() result: decoded body + coerced declared headers + raw response.""" + + data: T + headers: Dict[str, Any] + response: httpx.Response + + +def read_envelope_headers( + response: httpx.Response, specs: List[Tuple[str, str, str]] +) -> Dict[str, Any]: + """Coerce declared response headers per (name, key, type) specs; absent/unparsable omitted.""" + headers: Dict[str, Any] = {} + for name, key, type_ in specs: + raw = response.headers.get(name) + if raw is None: + continue + if type_ in ("integer", "number"): + try: + headers[key] = int(raw) if type_ == "integer" else float(raw) + except ValueError: + pass + elif type_ == "boolean": + lower = raw.strip().lower() + if lower in ("true", "false"): + headers[key] = lower == "true" + else: + headers[key] = raw + return headers + + +_IDEMPOTENT_METHODS = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"} +_TRANSIENT_STATUS = {408, 429, 500, 502, 503, 504} + + +def _default_retry_on(method: str, headers: Dict[str, str], response: Optional[httpx.Response]) -> bool: + safe = method.upper() in _IDEMPOTENT_METHODS or "Idempotency-Key" in headers + if not safe: + return False + return response is None or response.status_code in _TRANSIENT_STATUS + + +def _retry_delay(retry: Dict[str, Any], attempt: int, retry_after: Optional[str]) -> float: + if retry_after: + try: + return float(retry_after) + except ValueError: + pass # HTTP-date form: fall through to backoff + base = float(retry.get("retry_delay", 1.0)) + raw = base if retry.get("retry_strategy") == "fixed" else base * (2 ** (attempt - 1)) + return random.uniform(0, raw) if retry.get("jitter", True) is not False else raw + + +def send( + client: httpx.Client, + config: Dict[str, Any], + op: Dict[str, Any], + url: str, + *, + method: str, + headers: Optional[Dict[str, str]] = None, + params: Optional[Dict[str, Any]] = None, + json_body: Any = None, + content: Any = None, + data: Any = None, + files: Any = None, + timeout: Optional[float] = None, + idempotency_key: Any = None, + retry: Optional[Dict[str, Any]] = None, +) -> httpx.Response: + merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})} + effective_timeout = timeout if timeout is not None else config.get("timeout") + merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})} + + # One stable key per LOGICAL call — set before the retry loop so every + # attempt re-sends the same key; a caller-provided header always wins. + key = idempotency_key if idempotency_key is not None else config.get("idempotency_key") + if ( + key not in (None, False) + and method.upper() in ("POST", "PATCH") + and "Idempotency-Key" not in merged_headers + ): + merged_headers["Idempotency-Key"] = ( + key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4()) + ) + + context = { + "url": url, + "method": method.upper(), + "headers": merged_headers, + "body": json_body, + "operation": op, + } + middleware: List[Any] = config.get("middleware") or [] + for mw in middleware: + on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None) + if on_request: + on_request(context) + + max_attempts = 1 + int(merged_retry.get("retries", 0)) + retry_on = merged_retry.get("retry_on") or ( + lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response")) + ) + + attempt = 0 + while True: + attempt += 1 + try: + response = client.request( + context["method"], + context["url"], + headers=context["headers"], + params=params, + json=context["body"] if content is None and files is None and data is None else None, + content=content, + data=data, + files=files, + timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT, + ) + except httpx.TimeoutException: + if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}): + time.sleep(_retry_delay(merged_retry, attempt, None)) + continue + raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None + except httpx.TransportError: + if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}): + time.sleep(_retry_delay(merged_retry, attempt, None)) + continue + raise + + for mw in reversed(middleware): + on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None) + if on_response: + replaced = on_response(response, context) + if replaced is not None: + response = replaced + + if ( + not response.is_success + and attempt < max_attempts + and retry_on({"attempt": attempt, "response": response}) + ): + time.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after"))) + continue + return response + + +async def send_async( + client: httpx.AsyncClient, + config: Dict[str, Any], + op: Dict[str, Any], + url: str, + *, + method: str, + headers: Optional[Dict[str, str]] = None, + params: Optional[Dict[str, Any]] = None, + json_body: Any = None, + content: Any = None, + data: Any = None, + files: Any = None, + timeout: Optional[float] = None, + idempotency_key: Any = None, + retry: Optional[Dict[str, Any]] = None, +) -> httpx.Response: + """The async mirror of send() — same retry/timeout/idempotency semantics.""" + merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})} + effective_timeout = timeout if timeout is not None else config.get("timeout") + merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})} + key = idempotency_key if idempotency_key is not None else config.get("idempotency_key") + if ( + key not in (None, False) + and method.upper() in ("POST", "PATCH") + and "Idempotency-Key" not in merged_headers + ): + merged_headers["Idempotency-Key"] = ( + key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4()) + ) + context = { + "url": url, + "method": method.upper(), + "headers": merged_headers, + "body": json_body, + "operation": op, + } + middleware: List[Any] = config.get("middleware") or [] + for mw in middleware: + on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None) + if on_request: + on_request(context) + max_attempts = 1 + int(merged_retry.get("retries", 0)) + retry_on = merged_retry.get("retry_on") or ( + lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response")) + ) + attempt = 0 + while True: + attempt += 1 + try: + response = await client.request( + context["method"], + context["url"], + headers=context["headers"], + params=params, + json=context["body"] if content is None and files is None and data is None else None, + content=content, + data=data, + files=files, + timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT, + ) + except httpx.TimeoutException: + if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}): + await asyncio.sleep(_retry_delay(merged_retry, attempt, None)) + continue + raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None + except httpx.TransportError: + if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}): + await asyncio.sleep(_retry_delay(merged_retry, attempt, None)) + continue + raise + for mw in reversed(middleware): + on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None) + if on_response: + replaced = on_response(response, context) + if replaced is not None: + response = replaced + if ( + not response.is_success + and attempt < max_attempts + and retry_on({"attempt": attempt, "response": response}) + ): + await asyncio.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after"))) + continue + return response diff --git a/packages/client-generator/runtime/python/_sse.py b/packages/client-generator/runtime/python/_sse.py new file mode 100644 index 0000000000..66e6c3de5b --- /dev/null +++ b/packages/client-generator/runtime/python/_sse.py @@ -0,0 +1,161 @@ +# Server-Sent Events for generated Python clients — the TypeScript runtime's +# sse.ts semantics ported: frame parsing per the EventSource spec (retry must be +# ASCII digits; comment-only frames skipped; multi-line data joined with \n) and +# auto-reconnect resuming from the last event id via Last-Event-ID, with +# exponential backoff capped at 30s. JSON payloads are parsed when the operation +# declares a JSON event stream. +from __future__ import annotations + +import asyncio +import json +import random +import time +from dataclasses import dataclass +from typing import Any, AsyncIterator, Callable, Dict, Iterator, Optional + +import httpx + +_FRAME_DELIMITER = "\n\n" + + +@dataclass +class ServerSentEvent: + data: Any + event: Optional[str] = None + id: Optional[str] = None + retry: Optional[int] = None + + +def parse_sse_frame(raw: str, data_kind: str = "text") -> Optional[ServerSentEvent]: + event = None + data_lines = [] + event_id = None + retry = None + saw_field = False + for line in raw.replace("\r\n", "\n").replace("\r", "\n").split("\n"): + if line == "" or line.startswith(":"): + continue + field, _, value = line.partition(":") + if value.startswith(" "): + value = value[1:] + saw_field = True + if field == "event": + event = value + elif field == "data": + data_lines.append(value) + elif field == "id": + event_id = value + elif field == "retry" and value.isdigit(): + retry = int(value) + if not saw_field: + return None + text = "\n".join(data_lines) + data: Any = text + if data_kind == "json" and text != "": + data = json.loads(text) + return ServerSentEvent(data=data, event=event, id=event_id, retry=retry) + + +def iter_sse( + open_stream: Callable[[Dict[str, str]], Any], + data_kind: str = "text", + reconnect: bool = True, + reconnect_delay: float = 1.0, +) -> Iterator[ServerSentEvent]: + """Iterate an event stream. `open_stream(extra_headers)` must return an + httpx streaming-response context manager; it is reopened on dropped + connections with Last-Event-ID set (fresh call = fresh auth).""" + last_event_id: Optional[str] = None + server_retry: Optional[float] = None + failures = 0 + while True: + headers = {"Accept": "text/event-stream"} + if last_event_id is not None: + headers["Last-Event-ID"] = last_event_id + try: + with open_stream(headers) as response: + if response.status_code >= 400: + response.read() + raise httpx.HTTPStatusError( + f"SSE request failed with status {response.status_code}", + request=response.request, + response=response, + ) + failures = 0 + buffer = "" + for chunk in response.iter_text(): + buffer += chunk + while _FRAME_DELIMITER in buffer: + raw, buffer = buffer.split(_FRAME_DELIMITER, 1) + parsed = parse_sse_frame(raw, data_kind) + if parsed is not None: + if parsed.id is not None: + last_event_id = parsed.id + if parsed.retry is not None: + server_retry = parsed.retry / 1000 + yield parsed + # Clean end: flush a trailing frame, then finish (no reconnect). + if buffer.strip(): + parsed = parse_sse_frame(buffer, data_kind) + if parsed is not None: + yield parsed + return + except httpx.HTTPStatusError: + raise # a 4xx/5xx is definitive, not a dropped connection + except (httpx.TransportError, httpx.TimeoutException): + if not reconnect: + raise + failures += 1 + base = server_retry if server_retry is not None else reconnect_delay + time.sleep(random.uniform(0, min(base * (2 ** (failures - 1)), 30.0))) + + +async def aiter_sse( + open_stream: Callable[[Dict[str, str]], Any], + data_kind: str = "text", + reconnect: bool = True, + reconnect_delay: float = 1.0, +) -> AsyncIterator[ServerSentEvent]: + """Async mirror of iter_sse; `open_stream` returns an async context manager.""" + last_event_id: Optional[str] = None + server_retry: Optional[float] = None + failures = 0 + while True: + headers = {"Accept": "text/event-stream"} + if last_event_id is not None: + headers["Last-Event-ID"] = last_event_id + try: + async with open_stream(headers) as response: + if response.status_code >= 400: + await response.aread() + raise httpx.HTTPStatusError( + f"SSE request failed with status {response.status_code}", + request=response.request, + response=response, + ) + failures = 0 + buffer = "" + async for chunk in response.aiter_text(): + buffer += chunk + while _FRAME_DELIMITER in buffer: + raw, buffer = buffer.split(_FRAME_DELIMITER, 1) + parsed = parse_sse_frame(raw, data_kind) + if parsed is not None: + if parsed.id is not None: + last_event_id = parsed.id + if parsed.retry is not None: + server_retry = parsed.retry / 1000 + yield parsed + if buffer.strip(): + parsed = parse_sse_frame(buffer, data_kind) + if parsed is not None: + yield parsed + return + except httpx.HTTPStatusError: + raise + except (httpx.TransportError, httpx.TimeoutException): + if not reconnect: + raise + failures += 1 + base = server_retry if server_retry is not None else reconnect_delay + await asyncio.sleep(random.uniform(0, min(base * (2 ** (failures - 1)), 30.0))) diff --git a/packages/client-generator/runtime/python/_url.py b/packages/client-generator/runtime/python/_url.py new file mode 100644 index 0000000000..4e53569c28 --- /dev/null +++ b/packages/client-generator/runtime/python/_url.py @@ -0,0 +1,13 @@ +# URL assembly for generated Python clients — path-parameter substitution with +# percent-encoding, mirroring the TypeScript runtime's url.ts semantics. +from __future__ import annotations + +from typing import Any, Dict +from urllib.parse import quote + + +def build_url(server_url: str, path: str, path_params: Dict[str, Any]) -> str: + filled = path + for name, value in path_params.items(): + filled = filled.replace("{" + name + "}", quote(str(value), safe="")) + return server_url.rstrip("/") + filled diff --git a/packages/client-generator/scripts/ejected-skill.d.mts b/packages/client-generator/scripts/ejected-skill.d.mts new file mode 100644 index 0000000000..c8f75d743a --- /dev/null +++ b/packages/client-generator/scripts/ejected-skill.d.mts @@ -0,0 +1 @@ +export function ejectedSkill(source: string, name: string): string; diff --git a/packages/client-generator/scripts/ejected-skill.mjs b/packages/client-generator/scripts/ejected-skill.mjs new file mode 100644 index 0000000000..070e94842a --- /dev/null +++ b/packages/client-generator/scripts/ejected-skill.mjs @@ -0,0 +1,41 @@ +// The prepare-time transform from a generator's in-repo skill to the SKILL.md eject drops +// 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 +// 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 frontmatter = [ + '---', + `name: ${name}-generator`, + `description: Design of the ejected Redocly \`${name}\` client generator. Read it, and update it, before changing generators/${name}.mjs.`, + '---', + '', + ].join('\n'); + const titleEnd = source.indexOf('\n\n'); + const firstHeading = source.indexOf('\n## '); + const loopHeading = source.indexOf('\n## The modify loop'); + if (titleEnd === -1 || firstHeading === -1 || loopHeading === -1) { + 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\`):`, + '**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.`, + ].join('\n'); + const modifyLoop = [ + '## The modify loop', + '', + '1. Edit this skill: state the new behavior or decision.', + `2. Make \`generators/${name}.mjs\` match it.`, + '3. Run `redocly generate-client` and inspect the `git diff` of the generated output —', + ' generated files are never hand-edited.', + '', + `Newer built-in versions merge in with \`redocly eject-generator ${name} --update\`.`, + '', + ].join('\n'); + const designSections = source.slice(firstHeading, loopHeading); + return `${frontmatter}\n${source.slice(0, titleEnd)}\n\n${intro}\n${designSections}\n${modifyLoop}`; +} diff --git a/packages/client-generator/scripts/generate-eject-assets.mjs b/packages/client-generator/scripts/generate-eject-assets.mjs new file mode 100644 index 0000000000..2c4ef04b30 --- /dev/null +++ b/packages/client-generator/scripts/generate-eject-assets.mjs @@ -0,0 +1,240 @@ +import { build } from 'esbuild'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, 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. +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'); +mkdirSync(outDir, { recursive: true }); + +// The shared authoring skill ships as a skill too, so an agent in the user's repo loads +// it without being told to read a file. +mkdirSync(join(skillsDir, 'client-generators'), { recursive: true }); +writeFileSync( + join(skillsDir, 'client-generators', 'SKILL.md'), + [ + '---', + 'name: client-generators', + 'description: Write or change a Redocly client generator — the API model, the language-neutral helper toolkit, and the edit → regenerate → diff loop.', + '---', + '', + readFileSync(join(pkgRoot, 'eject-assets', 'AGENTS.md'), 'utf-8').trim(), + '', + ].join('\n') +); + +/** The provenance header every ejected file carries; `--update` reads the version from it. */ +function provenanceHeader(name) { + return ( + [ + `// Ejected from @redocly/client-generator@${version} — the built-in "${name}" generator.`, + '// This file is yours: edit freely; the generated client stays machine-owned and is', + '// rebuilt by `redocly generate-client`. Newer generator versions merge in with', + `// \`redocly eject-generator ${name} --update\`.`, + ].join('\n') + '\n' + ); +} + +/** + * The built-in compatibility table, read from its own source so an ejected file cannot + * declare a different contract from the built-in it came from. Only the metadata is + * wanted, so everything the table reaches for at call time is cut away: the generator + * modules behind `load`, and `@redocly/openapi-core`, which `meta.ts` uses only inside + * functions we never call. Nothing here may resolve into a package's `lib/` — this runs + * on `prepare`, before anything is compiled. + */ +async function loadBuiltinMeta() { + const bundle = join(pkgRoot, 'eject-assets', '.meta.mjs'); + await build({ + entryPoints: [join(pkgRoot, 'src', 'generators', 'meta.ts')], + outfile: bundle, + bundle: true, + format: 'esm', + platform: 'node', + target: 'node20', + plugins: [ + { + name: 'cut-call-time-imports', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /\/index\.js$/ }, (args) => ({ + path: args.path, + external: true, + })); + pluginBuild.onResolve({ filter: /^@redocly\/openapi-core$/ }, () => ({ + path: 'openapi-core', + namespace: 'unused-at-build-time', + })); + pluginBuild.onLoad({ filter: /.*/, namespace: 'unused-at-build-time' }, () => ({ + contents: 'export const logger = {};', + })); + }, + }, + ], + logLevel: 'warning', + }); + try { + return (await import(pathToFileURL(bundle).href)).BUILTIN_META; + } finally { + rmSync(bundle, { force: true }); + } +} + +const BUILTIN_META = await loadBuiltinMeta(); + +/** + * The default export the resolver loads, appended to every asset. It carries the same + * contract the built-in declares — `requires`, `errorModes`, `dateTypes`, `runtimes`, + * `notApplicable` — so an ejected generator still pulls its prerequisites in and is + * validated exactly like the built-in it replaces. + */ +function defaultExport(name, { run, sample, options, docs }) { + const { load: _load, ...contract } = BUILTIN_META[name]; + const fields = [` name: '${name}',`, ` run: ${run},`]; + if (sample !== undefined) fields.push(` sample: ${sample},`); + // The generator's own reference page travels with it: an ejected copy keeps + // documenting itself, and the page layout is the user's to change. + if (docs !== undefined) fields.push(` docs: ${docs},`); + if (options !== undefined) fields.push(` options: ${options},`); + for (const [key, value] of Object.entries(contract)) { + // Wrapped only when it would run long — the user owns and edits this file. + const inline = JSON.stringify(value); + const text = + inline.length <= 80 ? inline : JSON.stringify(value, null, 2).replaceAll('\n', '\n '); + fields.push(` ${key}: ${text},`); + } + // The caret range the ejected copy was written against: this version's model and + // helpers, plus every compatible release after it. + fields.push(` requiresGenerator: '^${version}',`); + 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'); + mkdirSync(join(skillsDir, `${name}-generator`), { recursive: true }); + writeFileSync(join(skillsDir, `${name}-generator`, 'SKILL.md'), ejectedSkill(skill, name)); +} + +const LANGUAGE = [ + { 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')", + }, +]; + +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 }); + } + 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 4516820f20..7dba49252a 100644 --- a/packages/client-generator/scripts/generate-runtime-sources.mjs +++ b/packages/client-generator/scripts/generate-runtime-sources.mjs @@ -1,6 +1,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; 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 @@ -19,6 +20,7 @@ const MODULES = [ 'sse', 'create-client', 'paginate', + 'cli', ]; const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); @@ -45,14 +47,158 @@ const entries = MODULES.map((name) => { return line.length <= 100 ? line : ` '${name}.ts':\n ${toStringLiteral(source)},`; }); +// Top-level declared names of every runtime module, precomputed here (with the TS +// parser, a devDependency) so the runtime-agnostic pipeline never needs `typescript` +// to build the reserved-name set. Mirrors collectDeclaredName's rules. +function declaredNames() { + const names = new Set(); + for (const name of MODULES) { + const source = readFileSync(join(runtimeDir, `${name}.ts`), 'utf-8'); + const file = ts.createSourceFile(`${name}.ts`, source, ts.ScriptTarget.Latest, false); + for (const statement of file.statements) { + if ( + (ts.isFunctionDeclaration(statement) || + ts.isClassDeclaration(statement) || + ts.isInterfaceDeclaration(statement) || + ts.isTypeAliasDeclaration(statement) || + ts.isEnumDeclaration(statement)) && + statement.name !== undefined + ) { + names.add(statement.name.text); + } else if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text); + } + } + } + } + return [...names].sort(); +} + +// The Python runtime (runtime/python/*.py) embeds the same way: hand-authored +// once, stitched into every generated Python client by the python generator. +const PYTHON_MODULES = [ + '_errors', + '_auth', + '_url', + '_decode', + '_send', + '_paginate', + '_sse', + '_multipart', +]; +const pythonDir = join(pkgRoot, 'runtime', 'python'); +const pythonOut = join(pkgRoot, 'src', 'emitters', 'python-runtime-sources.ts'); +const pythonEntries = PYTHON_MODULES.map((name) => { + const source = readFileSync(join(pythonDir, `${name}.py`), 'utf-8'); + const line = ` '${name}.py': ${toStringLiteral(source)},`; + return line.length <= 100 ? line : ` '${name}.py':\n ${toStringLiteral(source)},`; +}); +writeFileSync( + pythonOut, + [ + '// GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`.', + 'export const PYTHON_RUNTIME_SOURCES = {', + ...pythonEntries, + '} as const;', + '', + 'export type PythonRuntimeModuleName = keyof typeof PYTHON_RUNTIME_SOURCES;', + '', + ].join('\n') +); + +// 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 goSource = readFileSync(join(goDir, 'runtime.go'), 'utf-8'); +writeFileSync( + goOut, + [ + '// GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`.', + // oxfmt (printWidth 100) wraps the over-width const onto a continuation line. + `export const GO_RUNTIME_SOURCE =\n ${toStringLiteral(goSource)};`, + '', + ].join('\n') +); + +// 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 phpSource = readFileSync(join(phpDir, 'runtime.php'), 'utf-8'); +writeFileSync( + phpOut, + [ + '// GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`.', + // oxfmt (printWidth 100) wraps the over-width const onto a continuation line. + `export const PHP_RUNTIME_SOURCE =\n ${toStringLiteral(phpSource)};`, + '', + ].join('\n') +); + +// Stripped variants for inline embedding (emitters/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. +const KEEP_EXPORTS = { + 'types.ts': () => true, + 'errors.ts': (statement) => ts.isClassDeclaration(statement), + 'retry.ts': (statement) => + ts.isFunctionDeclaration(statement) && statement.name?.text === 'defaultRetryOn', + 'setup.ts': () => true, +}; + +function stripModule(name, source) { + const file = ts.createSourceFile( + '__embed.ts', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + const keeps = KEEP_EXPORTS[name]; + const parts = []; + for (const statement of file.statements) { + if (ts.isImportDeclaration(statement)) continue; + const text = source.slice(statement.getFullStart(), statement.end); + const exportModifier = ts + .getModifiers(statement) + ?.find((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); + if (exportModifier && !keeps?.(statement)) { + const at = exportModifier.getStart() - statement.getFullStart(); + parts.push(text.slice(0, at) + text.slice(at + 'export '.length)); + } else { + parts.push(text); + } + } + return parts.join('').trim(); +} + +const strippedEntries = MODULES.map((name) => { + const source = readFileSync(join(runtimeDir, `${name}.ts`), 'utf-8'); + const stripped = stripModule(`${name}.ts`, source); + const line = ` '${name}.ts': ${toStringLiteral(stripped)},`; + return line.length <= 100 ? line : ` '${name}.ts':\n ${toStringLiteral(stripped)},`; +}); + const content = [ '// GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`.', 'export const RUNTIME_SOURCES = {', ...entries, '} as const;', '', + '/** Inline-embed variants: imports dropped, `export` stripped outside the kept surface. */', + 'export const RUNTIME_SOURCES_STRIPPED = {', + ...strippedEntries, + '} as const;', + '', 'export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES;', '', + '/** Top-level declared names of the runtime modules — precomputed so the pipeline', + ' * builds the reserved-name set without the TypeScript parser. */', + 'export const RUNTIME_DECLARED_NAMES = [', + ...declaredNames().map((name) => ` '${name}',`), + '] as const;', + '', ].join('\n'); writeFileSync(outFile, content); diff --git a/packages/client-generator/scripts/typecheck-examples.mjs b/packages/client-generator/scripts/typecheck-examples.mjs index f2dc49f12f..8509c0fa39 100644 --- a/packages/client-generator/scripts/typecheck-examples.mjs +++ b/packages/client-generator/scripts/typecheck-examples.mjs @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process'; -import { readdirSync } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -19,7 +19,10 @@ const examples = readdirSync(examplesDir, { withFileTypes: true }) let failed = false; for (const name of examples) { - const res = spawnSync(tsc, ['--noEmit', '-p', join(examplesDir, name, 'tsconfig.json')], { + const tsconfig = join(examplesDir, name, 'tsconfig.json'); + // Language-SDK examples (python/go/php) have no TypeScript consumer to check. + if (!existsSync(tsconfig)) continue; + const res = spawnSync(tsc, ['--noEmit', '-p', tsconfig], { stdio: 'inherit', }); if (res.status !== 0) failed = true; diff --git a/packages/client-generator/src/__tests__/agents-template.test.ts b/packages/client-generator/src/__tests__/agents-template.test.ts new file mode 100644 index 0000000000..f0e2f2d6d9 --- /dev/null +++ b/packages/client-generator/src/__tests__/agents-template.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { AUTHORING_HELPER_NAMES } from '../authoring/index.js'; + +const template = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../../eject-assets/AGENTS.md'), + 'utf-8' +); + +describe('eject-assets/AGENTS.md (the authoring skill template)', () => { + it('documents every neutral helper — the skill cannot drift from the exports', () => { + for (const name of AUTHORING_HELPER_NAMES) { + expect(template.includes('`' + name), name).toBe(true); + } + }); + + it('carries the contract, the verify loop, and the feedback instruction', () => { + for (const marker of [ + 'GeneratedFile', + 'redocly generate-client', + 'never hand-edit', + 'sample(', + 'missing helper', + ]) { + expect(template.toLowerCase()).toContain(marker.toLowerCase()); + } + }); +}); diff --git a/packages/client-generator/src/__tests__/code-samples.test.ts b/packages/client-generator/src/__tests__/code-samples.test.ts new file mode 100644 index 0000000000..2c5bfa2814 --- /dev/null +++ b/packages/client-generator/src/__tests__/code-samples.test.ts @@ -0,0 +1,95 @@ +import { parseYaml } from '@redocly/openapi-core'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { outdent } from 'outdent'; + +import { generateClient } from '../index.js'; + +const SPEC = outdent` + openapi: 3.1.0 + info: { title: t, version: '1' } + servers: [{ url: https://api.example.com }] + paths: + /pets: + get: + operationId: listPets + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + items: { type: array, items: { type: string } } +`; + +type Overlay = { + overlay: string; + actions: Array<{ target: string; update: Record }>; +}; + +describe('codeSamples', () => { + it('emits an OpenAPI Overlay of x-codeSamples collected from generators that implement sample()', async () => { + const dir = await mkdtemp(join(tmpdir(), 'code-samples-')); + try { + await writeFile(join(dir, 'openapi.yaml'), SPEC); + await generateClient({ + api: join(dir, 'openapi.yaml'), + output: join(dir, 'client.ts'), + codeSamples: true, + }); + const overlay = parseYaml( + await readFile(join(dir, 'client.code-samples.yaml'), 'utf-8') + ) as Overlay; + expect(overlay.overlay).toBe('1.0.0'); + const action = overlay.actions.find((a) => a.target === "$.paths['/pets'].get")!; + const samples = action.update['x-codeSamples'] as Array>; + expect(samples[0]).toMatchObject({ lang: 'typescript' }); + expect(samples[0].source).toContain('listPets'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('imports the module each generator actually writes, not a hardcoded name', async () => { + // A snippet that imports `client` is wrong for every stem the languages rewrite: + // `openapi.client.ts` becomes `openapi_client.py`, and Go qualifies with `goPackage`. + const dir = await mkdtemp(join(tmpdir(), 'code-samples-module-')); + try { + await writeFile(join(dir, 'openapi.yaml'), SPEC); + await generateClient({ + api: join(dir, 'openapi.yaml'), + output: join(dir, 'openapi.client.ts'), + generators: ['typescript', 'python', 'go', 'php'], + goPackage: 'cafe', + codeSamples: true, + }); + const overlay = parseYaml( + await readFile(join(dir, 'openapi.client.code-samples.yaml'), 'utf-8') + ) as Overlay; + const samples = overlay.actions.find((action) => action.target === "$.paths['/pets'].get")! + .update['x-codeSamples'] as Array>; + const sourceOf = (lang: string) => samples.find((sample) => sample.lang === lang)!.source; + + expect(sourceOf('typescript')).toContain("from './openapi.client.js'"); + expect(sourceOf('python')).toContain('from openapi_client import Client'); + expect(sourceOf('php')).toContain("require 'openapi.client.php'"); + expect(sourceOf('go')).toContain('cafe.New(cafe.Config{})'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('emits no overlay file when codeSamples is off', async () => { + const dir = await mkdtemp(join(tmpdir(), 'code-samples-off-')); + try { + await writeFile(join(dir, 'openapi.yaml'), SPEC); + await generateClient({ api: join(dir, 'openapi.yaml'), output: join(dir, 'client.ts') }); + await expect(readFile(join(dir, 'client.code-samples.yaml'), 'utf-8')).rejects.toThrow(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/client-generator/src/__tests__/entry-weight.test.ts b/packages/client-generator/src/__tests__/entry-weight.test.ts index c2d257763f..10e05c3f71 100644 --- a/packages/client-generator/src/__tests__/entry-weight.test.ts +++ b/packages/client-generator/src/__tests__/entry-weight.test.ts @@ -46,3 +46,14 @@ describe('package root entry (lib/index.js)', () => { 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/__tests__/index.test.ts b/packages/client-generator/src/__tests__/index.test.ts index 1ab6e8dd65..ef8d047e10 100644 --- a/packages/client-generator/src/__tests__/index.test.ts +++ b/packages/client-generator/src/__tests__/index.test.ts @@ -1,3 +1,4 @@ +import { logger } from '@redocly/openapi-core'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -90,7 +91,7 @@ describe('collectGeneratedFiles', () => { outputPath: '/out/api.ts', outputMode: 'single', emit: {}, - generators: ['sdk'], + generators: ['typescript'], }); expect(files.length).toBe(1); expect(files[0].path).toBe('/out/api.ts'); @@ -102,17 +103,78 @@ describe('collectGeneratedFiles', () => { outputPath: '/out/api.ts', outputMode: 'single', emit: {}, - generators: ['sdk', 'sdk'], + generators: ['typescript', 'typescript'], }) ).toThrow(/already emitted/); }); + it('rejects a generated file path that escapes the output directory', () => { + const escapes = [ + { name: 'traversal', path: '../../outside.txt' }, + { name: 'absolute', path: '/etc/outside.txt' }, + ]; + for (const attempt of escapes) { + const registry = new Map([['rogue', { run: () => [{ path: attempt.path, content: 'x' }] }]]); + expect(() => + collectGeneratedFiles(model(), { + outputPath: '/out/api.ts', + outputMode: 'single', + emit: {}, + generators: ['rogue'], + registry, + }) + ).toThrow(/Generator "rogue" failed: .*escapes the output directory/); + } + // A relative path resolves against the output directory — the same base the guard + // checked — never against the cwd at write time. + const relativeRegistry = new Map([ + ['relative', { run: () => [{ path: 'fixtures/data.json', content: '{}' }] }], + ]); + expect( + collectGeneratedFiles(model(), { + outputPath: '/out/api.ts', + outputMode: 'single', + emit: {}, + generators: ['relative'], + registry: relativeRegistry, + })[0].path + ).toBe('/out/fixtures/data.json'); + // Subdirectories under the output directory stay legal (mock fixtures, split files). + const registry = new Map([ + ['nested', { run: () => [{ path: '/out/fixtures/data.json', content: '{}' }] }], + ]); + expect( + collectGeneratedFiles(model(), { + outputPath: '/out/api.ts', + outputMode: 'single', + emit: {}, + generators: ['nested'], + registry, + }) + ).toHaveLength(1); + }); + + it('rejects a run() result that is not an array of { path, content } files', () => { + for (const bad of [undefined, 'files', [{ path: '', content: 'x' }], [{ path: '/out/a' }]]) { + const registry = new Map([['broken', { run: () => bad as never }]]); + expect(() => + collectGeneratedFiles(model(), { + outputPath: '/out/api.ts', + outputMode: 'single', + emit: {}, + generators: ['broken'], + registry, + }) + ).toThrow(/Generator "broken" failed: run\(\) must return/); + } + }); + it('supports runtime: package with outputMode: split (the shared emitter serves both)', () => { const files = collectGeneratedFiles(model(), { outputPath: '/out/api.ts', outputMode: 'split', emit: { runtime: 'package' }, - generators: ['sdk'], + generators: ['typescript'], }); // No schemas in the model → only the entry file. expect(files.map((f) => f.path)).toEqual(['/out/api.ts']); @@ -131,6 +193,37 @@ describe('generateClient — end-to-end orchestration', () => { await rm(workDir, { recursive: true, force: true }); }); + it('reports a parameter name used in two locations, which every SDK has to spell once', async () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + const api = join(workDir, 'repeated.yaml'); + await writeFile( + api, + outdent` + openapi: 3.1.0 + info: { title: Repeated, version: 1.0.0 } + paths: + /things/{id}: + get: + operationId: getThing + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + - { name: id, in: query, required: false, schema: { type: integer } } + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } + `, + 'utf-8' + ); + await generateClient({ api, output: join(workDir, 'client.ts') }); + expect(warn.mock.calls.map(([message]) => message).join('\n')).toContain( + 'operation "getThing" uses "id" in more than one parameter location' + ); + warn.mockRestore(); + }); + it('writes the generated file to disk and reports its size', async () => { const api = join(workDir, 'spec.yaml'); await writeFile( @@ -161,9 +254,7 @@ describe('generateClient — end-to-end orchestration', () => { expect(result.bytes).toBeGreaterThan(0); const contents = await readFile(output, 'utf-8'); - expect(contents).toContain( - 'export const ping = (init?: I): Promise, I>>' - ); + expect(contents).toContain('export const { ping } = client;'); expect(contents).toContain('// Generated by @redocly/client-generator'); // bytes should match what we wrote. expect(result.bytes).toBe(Buffer.byteLength(contents, 'utf-8')); @@ -252,9 +343,8 @@ describe('generateClient — end-to-end orchestration', () => { 'pagination: { style: "cursor", param: "cursor", nextCursor: "/nextCursor", items: "/orders" }' ); expect(contents).toContain('item: string;'); - expect(contents).toContain( - '{ pages: client.listOrders.pages, items: client.listOrders.items });' - ); + // `.pages`/`.items` ride the client method the binding points at. + expect(contents).toContain('export const { listOrders } = client;'); }); it('normalizes a Swagger 2.0 document before generating', async () => { @@ -295,9 +385,7 @@ describe('generateClient — end-to-end orchestration', () => { expect(result.bytes).toBeGreaterThan(0); const contents = await readFile(output, 'utf-8'); - expect(contents).toContain( - 'export const listItems = (' - ); + expect(contents).toContain('export const { listItems } = client;'); expect(contents).toContain('export type Item'); expect(contents).toContain('serverUrl: "https://api.example.com/v1"'); }); diff --git a/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts b/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts new file mode 100644 index 0000000000..160eba8de4 --- /dev/null +++ b/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts @@ -0,0 +1,67 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// The pipeline (loadSpec → IR → resolve → run) must not load `typescript` +// unless a TS-emitting generator is actually selected. Built-ins are reached +// only through dynamic imports in generators/meta.js, which this static walk +// deliberately does not follow — so any static leak fails here. +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 + .split('/') + .slice(0, specifier.startsWith('@') ? 2 : 1) + .join('/') + ); + } + } + } + return { files, externals }; +} + +// Emitter modules the IR legitimately shares (identifier/name sanitizing) — all +// pure string/data helpers with no `typescript` import. Anything else from +// emitters/ appearing in the pipeline graph is a leak. +const PURE_EMITTER_HELPERS = new Set([ + 'auth.js', + 'identifier.js', + 'reserved-names.js', + 'runtime-sources.js', + 'support.js', +]); + +describe('pipeline (lib/pipeline.js)', () => { + it('statically loads no typescript and only the pure emitter helpers', () => { + 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([]); + }); +}); + +describe('the sdk generator itself (lib/generators/typescript/index.js)', () => { + it('loads no typescript — the whole emit path is text templates (setup baking stays lazy)', () => { + const { externals } = staticGraph(join(libDir, 'generators/typescript/index.js')); + expect(externals.has('typescript')).toBe(false); + }); +}); diff --git a/packages/client-generator/src/__tests__/plugin.test.ts b/packages/client-generator/src/__tests__/plugin.test.ts index d41927f7f8..f436b11390 100644 --- a/packages/client-generator/src/__tests__/plugin.test.ts +++ b/packages/client-generator/src/__tests__/plugin.test.ts @@ -1,25 +1,17 @@ -import { - operationSignature, - pascalCase, - printStatements, - safeIdent, - schemaToTypeNode, - ts, -} from '../generate.js'; +import { codeLiteral, operationSignature, pascalCase, safeIdent, tsType } from '../generate.js'; import { type CustomGenerator, defineGenerator } from '../plugin.js'; describe('plugin entry', () => { it('defineGenerator returns its argument unchanged', () => { - const gen: CustomGenerator = { name: 'route-map', requires: ['sdk'], run: () => [] }; + const gen: CustomGenerator = { name: 'route-map', requires: ['typescript'], run: () => [] }; expect(defineGenerator(gen)).toBe(gen); }); - it('re-exports the emit toolkit the built-in generators use', () => { + it('re-exports the text toolkit the built-in generators use', () => { // Value re-exports are reachable and usable from the public entry. - expect(typeof ts.factory).toBe('object'); - expect(typeof printStatements).toBe('function'); + expect(tsType({ kind: 'scalar', scalar: 'string' })).toBe('string'); + expect(codeLiteral({ id: 'x' })).toBe('{ id: "x" }'); expect(typeof operationSignature).toBe('function'); - expect(typeof schemaToTypeNode).toBe('function'); expect(pascalCase('pet')).toBe('Pet'); expect(safeIdent('123')).not.toBe('123'); }); diff --git a/packages/client-generator/src/authoring/__tests__/exports.test.ts b/packages/client-generator/src/authoring/__tests__/exports.test.ts new file mode 100644 index 0000000000..41c1e7b6e5 --- /dev/null +++ b/packages/client-generator/src/authoring/__tests__/exports.test.ts @@ -0,0 +1,21 @@ +import * as root from '../../index.js'; +import { AUTHORING_HELPER_NAMES } from '../index.js'; + +// The /generate entry pulls in the whole emitter graph on first import, which can +// exceed the 5s default on a loaded machine. +vi.setConfig({ testTimeout: 60_000 }); + +describe('authoring toolkit exports', () => { + it('exports every helper from the package root (the TS-free entry)', () => { + for (const name of AUTHORING_HELPER_NAMES) { + expect((root as Record)[name], name).toBeDefined(); + } + }); + + it('exports the same helpers from /generate for toolkit-entry consistency', async () => { + const generate = await import('../../generate.js'); + for (const name of AUTHORING_HELPER_NAMES) { + expect((generate as Record)[name], name).toBeDefined(); + } + }); +}); diff --git a/packages/client-generator/src/authoring/__tests__/naming.test.ts b/packages/client-generator/src/authoring/__tests__/naming.test.ts new file mode 100644 index 0000000000..d037cd3e22 --- /dev/null +++ b/packages/client-generator/src/authoring/__tests__/naming.test.ts @@ -0,0 +1,77 @@ +import { casing, identifierFor, RESERVED_WORDS, uniqueIdentifiers } from '../naming.js'; + +describe('casing', () => { + it('splits on delimiters and case boundaries, handling acronyms', () => { + for (const input of ['order-item', 'order_item', 'orderItem', 'OrderItem', 'order item']) { + expect(casing.camel(input)).toBe('orderItem'); + expect(casing.pascal(input)).toBe('OrderItem'); + expect(casing.snake(input)).toBe('order_item'); + expect(casing.screaming(input)).toBe('ORDER_ITEM'); + } + expect(casing.snake('APIKey')).toBe('api_key'); + expect(casing.pascal('api_key_v2')).toBe('ApiKeyV2'); + }); + + it('keeps a plural acronym as one word (Rebilly title "All APIs")', () => { + expect(casing.pascal('All APIs')).toBe('AllApis'); + expect(casing.snake('externalIDs')).toBe('external_ids'); + // A real word after the acronym still splits. + expect(casing.pascal('APIServer')).toBe('ApiServer'); + }); + + it('names signed numbers Plus*/Minus* so +1 and -1 stay distinct (GitHub reactions)', () => { + expect(casing.pascal('+1')).toBe('Plus1'); + expect(casing.pascal('-1')).toBe('Minus1'); + expect(casing.snake('+1')).toBe('plus_1'); + expect(casing.screaming('-1')).toBe('MINUS_1'); + // A minus that is just a word delimiter is untouched. + expect(casing.pascal('x-header')).toBe('XHeader'); + }); +}); + +describe('identifierFor', () => { + it('sanitizes invalid characters and leading digits, then applies the style', () => { + expect(identifierFor('2nd-item', { style: 'snake' })).toBe('_2nd_item'); + expect(identifierFor('user.name', { style: 'camel' })).toBe('userName'); + }); + + it('suffixes an underscore for reserved words of the target language', () => { + expect(identifierFor('class', { style: 'snake', reserved: RESERVED_WORDS.python })).toBe( + 'class_' + ); + expect(identifierFor('type', { style: 'camel', reserved: RESERVED_WORDS.go })).toBe('type_'); + expect(identifierFor('order', { style: 'camel', reserved: RESERVED_WORDS.python })).toBe( + 'order' + ); + expect(identifierFor('class', { style: 'camel', reserved: RESERVED_WORDS.php })).toBe('class_'); + expect(identifierFor('list', { style: 'camel', reserved: RESERVED_WORDS.php })).toBe('list_'); + expect(identifierFor('echo', { style: 'camel', reserved: RESERVED_WORDS.php })).toBe('echo_'); + }); +}); + +describe('uniqueIdentifiers', () => { + it('separates a repeat the way the casing style spells names', () => { + // OpenAPI lets one name appear in two locations; a signature cannot declare it twice. + expect(uniqueIdentifiers(['id', 'id'], { style: 'snake' })).toEqual(['id', 'id_2']); + expect(uniqueIdentifiers(['id', 'id', 'id'], { style: 'camel' })).toEqual(['id', 'id2', 'id3']); + }); + + it('moves aside for a name the caller already took', () => { + expect( + uniqueIdentifiers(['body', 'timeout'], { style: 'snake', taken: ['self', 'body', 'timeout'] }) + ).toEqual(['body_2', 'timeout_2']); + }); + + it('applies the style and the reserved-word rule first', () => { + expect( + uniqueIdentifiers(['order-id', 'class'], { + style: 'snake', + reserved: RESERVED_WORDS.python, + }) + ).toEqual(['order_id', 'class_']); + }); + + it('keeps distinct names distinct, and needs no suffix when nothing clashes', () => { + expect(uniqueIdentifiers(['a', 'b'], { style: 'camel', taken: ['c'] })).toEqual(['a', 'b']); + }); +}); diff --git a/packages/client-generator/src/authoring/__tests__/pagination.test.ts b/packages/client-generator/src/authoring/__tests__/pagination.test.ts new file mode 100644 index 0000000000..388e4860d4 --- /dev/null +++ b/packages/client-generator/src/authoring/__tests__/pagination.test.ts @@ -0,0 +1,76 @@ +import type { OperationModel } from '../../intermediate-representation/model.js'; +import { paginationRuleFor } from '../pagination.js'; + +function op(extra: Partial = {}): OperationModel { + return { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + tags: [], + pathParams: [], + queryParams: [ + { name: 'after', in: 'query', required: false, schema: { kind: 'scalar', scalar: 'string' } }, + ], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [], + errorResponses: [], + ...extra, + } as unknown as OperationModel; +} + +const CURSOR = { style: 'cursor', cursorParam: 'after', nextCursor: '/next', items: '/items' }; + +describe('paginationRuleFor', () => { + it('per-operation config beats the x-redoclyPagination extension', () => { + const operation = op({ paginationExtension: { ...CURSOR, items: '/fromExtension' } }); + const rule = paginationRuleFor(operation, { operations: { listOrders: CURSOR } })!; + expect(rule).toEqual({ + style: 'cursor', + param: 'after', + nextCursor: '/next', + items: '/items', + }); + }); + + it('falls back to the extension, then to a fitting convention', () => { + expect(paginationRuleFor(op({ paginationExtension: CURSOR }), undefined)).toMatchObject({ + style: 'cursor', + param: 'after', + }); + // Convention fits: the advance param exists on the operation. + expect(paginationRuleFor(op(), CURSOR)).toMatchObject({ style: 'cursor', param: 'after' }); + // Convention does not fit: no such query param. + expect(paginationRuleFor(op(), { ...CURSOR, cursorParam: 'ghost' })).toBeUndefined(); + }); + + it('applies a link convention only to operations that document a Link header', () => { + // The convention's structural fit signal for `link` is a documented `Link` response + // header — same rule the TypeScript emitter and the docs state. Without the gate, + // `client.pagination.style: link` would attach page iterators to EVERY operation. + const convention = { style: 'link', items: '/items' }; + const plain = op(); + expect(paginationRuleFor(plain, convention)).toBeUndefined(); + + const linked = op({ + successResponseHeaders: [{ name: 'link', schema: { kind: 'scalar', scalar: 'string' } }], + } as unknown as Partial); + expect(paginationRuleFor(linked, convention)).toEqual({ style: 'link', items: '/items' }); + + // An EXPLICIT rule (per-op or extension) is a declaration, not a convention — it + // still applies, mirroring the TypeScript emitter's explicit-rule path. + expect(paginationRuleFor(plain, { operations: { listOrders: convention } })).toEqual({ + style: 'link', + items: '/items', + }); + }); + + it('honors exclude and returns undefined without any source', () => { + expect( + paginationRuleFor(op({ paginationExtension: CURSOR }), { exclude: ['listOrders'] }) + ).toBeUndefined(); + expect(paginationRuleFor(op(), undefined)).toBeUndefined(); + }); +}); diff --git a/packages/client-generator/src/authoring/__tests__/printer.test.ts b/packages/client-generator/src/authoring/__tests__/printer.test.ts new file mode 100644 index 0000000000..07e462cc16 --- /dev/null +++ b/packages/client-generator/src/authoring/__tests__/printer.test.ts @@ -0,0 +1,36 @@ +import { Printer } from '../printer.js'; + +describe('Printer', () => { + it('builds indented blocks in any language without manual whitespace bookkeeping', () => { + const printer = new Printer(); + printer.line('class Pet:').indent(() => { + printer.line('def __init__(self):').indent(() => { + printer.line('self.name = name'); + }); + }); + expect(printer.toString()).toBe('class Pet:\n def __init__(self):\n self.name = name\n'); + }); + + it('block() without a close suits dedent-terminated languages (Python)', () => { + const printer = new Printer(' '); + printer.block('class Pet:', () => { + printer.line('name: str'); + }); + printer.line('PETS = []'); + expect(printer.toString()).toBe('class Pet:\n name: str\nPETS = []\n'); + }); + + it('block() wraps open/body/close; blank() emits an empty line without indentation', () => { + const printer = new Printer(' '); + printer.block( + 'func main() {', + () => { + printer.line('run()'); + printer.blank(); + printer.line('done()'); + }, + '}' + ); + expect(printer.toString()).toBe('func main() {\n run()\n\n done()\n}\n'); + }); +}); diff --git a/packages/client-generator/src/authoring/__tests__/schema.test.ts b/packages/client-generator/src/authoring/__tests__/schema.test.ts new file mode 100644 index 0000000000..d1b156e7b4 --- /dev/null +++ b/packages/client-generator/src/authoring/__tests__/schema.test.ts @@ -0,0 +1,145 @@ +import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; +import { + discriminatorCases, + schemaAtPointer, + docText, + enumValues, + flattenAllOf, + isNullable, + unwrapNullable, +} from '../schema.js'; + +const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' }; + +function model(schemas: Record): ApiModel { + return { + title: 't', + version: '1', + services: [], + schemas: Object.entries(schemas).map(([name, schema]) => ({ name, schema })), + securitySchemes: [], + } as unknown as ApiModel; +} + +describe('flattenAllOf', () => { + it('merges intersection members across refs; later members win on property conflicts', () => { + const collection: SchemaModel = { + kind: 'object', + properties: [ + { name: 'offset', schema: { kind: 'scalar', scalar: 'integer' }, required: false }, + { name: 'kind', schema: STRING, required: false }, + ], + }; + const listPage: SchemaModel = { + kind: 'intersection', + members: [ + { kind: 'ref', name: 'Collection' }, + { + kind: 'object', + properties: [ + { name: 'items', schema: { kind: 'array', items: STRING }, required: true }, + { name: 'kind', schema: { kind: 'literal', value: 'list' }, required: true }, + ], + }, + ], + }; + const flat = flattenAllOf(listPage, model({ Collection: collection }))!; + const names = flat.properties.map((property) => property.name); + expect(names).toEqual(['offset', 'kind', 'items']); + const kind = flat.properties.find((property) => property.name === 'kind')!; + expect(kind.schema).toEqual({ kind: 'literal', value: 'list' }); + expect(kind.required).toBe(true); + }); + + it('flattens a plain object and nested intersections; bails to undefined on a scalar member', () => { + const object: SchemaModel = { kind: 'object', properties: [] }; + expect(flattenAllOf(object, model({}))).toEqual({ properties: [], description: undefined }); + const withScalar: SchemaModel = { kind: 'intersection', members: [object, STRING] }; + expect(flattenAllOf(withScalar, model({}))).toBeUndefined(); + const nested: SchemaModel = { + kind: 'intersection', + members: [{ kind: 'intersection', members: [object] }], + }; + expect(flattenAllOf(nested, model({}))).toEqual({ properties: [], description: undefined }); + }); +}); + +describe('discriminatorCases', () => { + it('returns the neutral dispatch table with each case schema resolved', () => { + const cat: SchemaModel = { kind: 'object', properties: [] }; + const union: SchemaModel = { + kind: 'union', + members: [{ kind: 'ref', name: 'Cat' }], + discriminator: { propertyName: 'petType', mapping: [{ value: 'cat', schemaName: 'Cat' }] }, + }; + expect(discriminatorCases(union, model({ Cat: cat }))).toEqual({ + property: 'petType', + cases: [{ value: 'cat', schemaName: 'Cat', schema: cat }], + }); + expect(discriminatorCases({ kind: 'union', members: [] }, model({}))).toBeUndefined(); + }); +}); + +describe('nullability and enums', () => { + it('detects and strips null union members', () => { + const nullable: SchemaModel = { kind: 'union', members: [STRING, { kind: 'null' }] }; + expect(isNullable(nullable)).toBe(true); + expect(unwrapNullable(nullable)).toEqual(STRING); + expect(isNullable(STRING)).toBe(false); + expect(unwrapNullable(STRING)).toBe(STRING); + }); + + it('extracts enum values with SCREAMING member-name suggestions', () => { + const status: SchemaModel = { + kind: 'enum', + values: ['in-progress', 'done', 404], + scalar: 'string', + }; + expect(enumValues(status)).toEqual({ + values: ['in-progress', 'done', 404], + scalar: 'string', + memberNames: ['IN_PROGRESS', 'DONE', 'VALUE_404'], + }); + expect(enumValues(STRING)).toBeUndefined(); + }); +}); + +describe('docText', () => { + it('normalizes a description into trimmed lines, dropping blank edges', () => { + expect(docText(' First line.\r\n\r\nSecond.\n')).toEqual(['First line.', '', 'Second.']); + expect(docText(undefined)).toEqual([]); + }); +}); + +describe('schemaAtPointer', () => { + it('walks object properties, arrays, records, and intersections through refs', () => { + const order: SchemaModel = { + kind: 'object', + properties: [{ name: 'id', schema: STRING, required: true }], + }; + const page: SchemaModel = { + kind: 'intersection', + members: [ + { + kind: 'object', + properties: [ + { + name: 'items', + schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, + required: true, + }, + ], + }, + ], + }; + const m = model({ Order: order, Page: page }); + expect(schemaAtPointer({ kind: 'ref', name: 'Page' }, '/items/0', m)).toEqual(order); + expect(schemaAtPointer(page, '/items', m)).toEqual({ + kind: 'array', + items: { kind: 'ref', name: 'Order' }, + }); + expect(schemaAtPointer(page, '/missing', m)).toBeUndefined(); + expect(schemaAtPointer(page, 'items', m)).toBeUndefined(); + expect(schemaAtPointer(page, '', m)).toEqual(page); + }); +}); diff --git a/packages/client-generator/src/authoring/index.ts b/packages/client-generator/src/authoring/index.ts new file mode 100644 index 0000000000..67b9e36a7e --- /dev/null +++ b/packages/client-generator/src/authoring/index.ts @@ -0,0 +1,49 @@ +// The language-neutral authoring toolkit barrel. Pure functions over the IR — +// no typescript, no @redocly/openapi-core, no Node builtins — so it is exported +// from the package ROOT: a custom generator importing only these stays TS-free. + +// A generator rejects an option it can't honor by throwing this — the CLI prints its +// message as a user error instead of an unexpected crash. Part of the authoring surface +// because an ejected generator only imports from this barrel. +export { NotSupportedError } from '../errors.js'; +export { Printer } from './printer.js'; +export type { DateType } from './options.js'; +export { casing, identifierFor, RESERVED_WORDS, uniqueIdentifiers } from './naming.js'; +export { paginationRuleFor, type NeutralPaginationRule } from './pagination.js'; +// The Markdown reference page a generator's `docs` hook returns. Here rather than in the +// emitters, so a generator ejected as source reaches it through the package like we do. +export { + renderReferencePage, + type ReferenceLanguage, + type ReferencePageOptions, +} from './reference-page.js'; +export { + discriminatorCases, + docText, + enumValues, + flattenAllOf, + headerCoerceType, + isNullable, + schemaAtPointer, + unwrapNullable, +} from './schema.js'; + +/** Every value exported above — the skill's helper table and Tier-2 telemetry key off this. */ +export const AUTHORING_HELPER_NAMES = [ + 'Printer', + 'casing', + 'identifierFor', + 'uniqueIdentifiers', + 'RESERVED_WORDS', + 'flattenAllOf', + 'discriminatorCases', + 'isNullable', + 'unwrapNullable', + 'enumValues', + 'docText', + 'headerCoerceType', + 'schemaAtPointer', + 'paginationRuleFor', + 'renderReferencePage', + 'NotSupportedError', +] as const; diff --git a/packages/client-generator/src/authoring/naming.ts b/packages/client-generator/src/authoring/naming.ts new file mode 100644 index 0000000000..0621d68182 --- /dev/null +++ b/packages/client-generator/src/authoring/naming.ts @@ -0,0 +1,120 @@ +// 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 +// other output languages. + +/** Split on delimiters and camel/acronym boundaries: 'APIKey-v2' → ['api', 'key', 'v2']. */ +function splitWords(name: string): string[] { + return ( + name + // A leading sign on a number is meaning, not a delimiter: '+1'/'-1' (GitHub + // reactions) must not collapse to the same identifier. + .replace(/^\+(?=\d)/, 'plus ') + .replace(/^-(?=\d)/, 'minus ') + // A plural acronym is one word: fold the trailing 's' in so the + // acronym-boundary rule below doesn't split 'APIs' into 'AP Is'. + .replace(/([A-Z]{2,})s(?![a-z])/g, '$1S') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .split(/[^A-Za-z0-9]+/) + .filter((word) => word !== '') + .map((word) => word.toLowerCase()) + ); +} + +const capitalize = (word: string) => word.charAt(0).toUpperCase() + word.slice(1); + +export const casing = { + camel: (name: string): string => { + const [first, ...rest] = splitWords(name); + return (first ?? '') + rest.map(capitalize).join(''); + }, + pascal: (name: string): string => splitWords(name).map(capitalize).join(''), + snake: (name: string): string => splitWords(name).join('_'), + screaming: (name: string): string => splitWords(name).join('_').toUpperCase(), +}; + +/** Keyword sets for the first-party target languages; authors pass their own set for others. */ +export const RESERVED_WORDS: Record<'typescript' | 'python' | 'go' | 'php', ReadonlySet> = { + // prettier-ignore + typescript: 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', 'implements', 'interface', 'let', + 'package', 'private', 'protected', 'public', 'static', 'yield', 'await', + ]), + // prettier-ignore + python: new Set([ + 'false', 'none', 'true', 'and', 'as', 'assert', 'async', 'await', 'break', 'class', + 'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', + 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', + 'try', 'while', 'with', 'yield', 'match', 'case', 'type', + ]), + // prettier-ignore + go: new Set([ + 'break', 'case', 'chan', 'const', 'continue', 'default', 'defer', 'else', 'fallthrough', + 'for', 'func', 'go', 'goto', 'if', 'import', 'interface', 'map', 'package', 'range', + 'return', 'select', 'struct', 'switch', 'type', 'var', + ]), + // PHP keywords + compile-time constants are case-insensitive; the set stays lowercase + // because `identifierFor` matches on the lowercased candidate. + // prettier-ignore + php: new Set([ + 'abstract', 'and', 'array', 'as', 'break', 'callable', 'case', 'catch', 'class', 'clone', + 'const', 'continue', 'declare', 'default', 'die', 'do', 'echo', 'else', 'elseif', 'empty', + 'enddeclare', 'endfor', 'endforeach', 'endif', 'endswitch', 'endwhile', 'enum', 'eval', + 'exit', 'extends', 'final', 'finally', 'fn', 'for', 'foreach', 'function', 'global', 'goto', + 'if', 'implements', 'include', 'instanceof', 'insteadof', 'interface', 'isset', 'list', + 'match', 'namespace', 'new', 'or', 'print', 'private', 'protected', 'public', 'readonly', + 'require', 'return', 'static', 'switch', 'throw', 'trait', 'try', 'unset', 'use', 'var', + 'while', 'xor', 'yield', 'true', 'false', 'null', 'int', 'float', 'bool', 'string', 'void', + 'iterable', 'object', 'mixed', 'never', 'self', 'parent', + ]), +}; + +/** + * A safe identifier for any C-like or snake-case language: applies the casing + * style (which strips invalid characters), prefixes `_` when the result starts + * with a digit, and suffixes `_` when it is a reserved word — the cross-language + * convention (Python's `class_`, Go's `type_`). + */ +export function identifierFor( + name: string, + options: { style?: keyof typeof casing; reserved?: ReadonlySet } = {} +): string { + const styled = casing[options.style ?? 'camel'](name); + const base = styled === '' ? '_' : /^[0-9]/.test(styled) ? `_${styled}` : styled; + return options.reserved?.has(base.toLowerCase()) ? `${base}_` : base; +} + +/** + * `identifierFor` over a list of wire names, made unique among themselves and among the + * names already `taken` — `id`, `id_2`, `id_3`, … A language that passes parameters as + * separate arguments needs this: OpenAPI lets one name appear in two locations (`id` in the + * path AND in the query), and a signature cannot declare that name twice. Seed `taken` with + * the argument slots the method itself declares (a body, a headers bag, a timeout), so a + * parameter named after one of them moves aside instead of shadowing it. + * + * The wire name is untouched: only the binding moves, so the request is unchanged. + */ +export function uniqueIdentifiers( + names: readonly string[], + options: { + style?: keyof typeof casing; + reserved?: ReadonlySet; + taken?: Iterable; + } = {} +): string[] { + const used = new Set(options.taken ?? []); + // The separator follows the casing style, so the result stays idiomatic: `order_id_2` in + // snake-case languages, `orderId2` where names run together. + const separator = options.style === 'snake' || options.style === 'screaming' ? '_' : ''; + return names.map((name) => { + const base = identifierFor(name, options); + let unique = base; + for (let suffix = 2; used.has(unique); suffix++) unique = `${base}${separator}${suffix}`; + used.add(unique); + return unique; + }); +} diff --git a/packages/client-generator/src/authoring/options.ts b/packages/client-generator/src/authoring/options.ts new file mode 100644 index 0000000000..7005fabecf --- /dev/null +++ b/packages/client-generator/src/authoring/options.ts @@ -0,0 +1,12 @@ +// Neutral option types every generator (any output language) may need to honor. +// They live in the authoring toolkit — not the TypeScript emitters — so a language +// generator can type its plumbing without importing TS-specific modules. + +/** + * How `format: date-time`/`date` string fields are typed: + * - `'string'` (default): the wire shape — an ISO string. + * - `'Date'`: the target language's date object (`Date` in TypeScript, `datetime` + * in Python, `time.Time` in Go, `DateTimeImmutable` in PHP). The generated + * client converts on the wire boundary, so the values match the types. + */ +export type DateType = 'string' | 'Date'; diff --git a/packages/client-generator/src/authoring/pagination.ts b/packages/client-generator/src/authoring/pagination.ts new file mode 100644 index 0000000000..cec9c69218 --- /dev/null +++ b/packages/client-generator/src/authoring/pagination.ts @@ -0,0 +1,62 @@ +// Language-neutral pagination-rule resolution: which rule applies to an operation +// (per-op config > the `x-redoclyPagination` extension > a fitting convention) and +// its normalized shape. Declaration-based — the TS toolkit's static fit VERIFICATION +// (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'; + +/** The normalized rule a generator renders into its runtime's pagination spec. */ +export type NeutralPaginationRule = { + style: string; + /** The advance query parameter (cursor/offset/page styles). */ + param?: string; + nextCursor?: string; + hasMore?: string; + limitParam?: string; + items?: string; +}; + +/** + * Pagination for one operation. The convention rule applies only where it structurally + * fits — the advance parameter exists on the operation, or for `link` (which has no + * parameter) the success response documents a `Link` header; `exclude` kills every source. + * Returns undefined when the operation does not paginate. + */ +export function paginationRuleFor( + op: OperationModel, + config: Record | undefined, + _model?: ApiModel +): NeutralPaginationRule | undefined { + const configuration = config ?? {}; + const id = op.specName ?? op.name; + if (Array.isArray(configuration.exclude) && configuration.exclude.includes(id)) { + return undefined; + } + const operations = (configuration.operations ?? {}) as Record>; + let rule: Record | undefined = + operations[id] ?? (op.paginationExtension as Record | undefined); + if (rule === undefined && typeof configuration.style === 'string') { + const { exclude: _exclude, operations: _operations, ...convention } = configuration; + const advance = convention.style === 'cursor' ? convention.cursorParam : convention.offsetParam; + // A convention needs a structural fit signal: the advance parameter for cursor/offset/ + // page, and a documented `Link` response header for `link` (which has no parameter) — + // the same gate the TypeScript emitter applies. Without it, a link convention would + // attach page iterators to every operation in the description. + const fits = + convention.style === 'link' + ? op.successResponseHeaders?.some((header) => header.name === 'link') === true + : typeof advance === 'string' && op.queryParams.some((param) => param.name === advance); + if (fits) rule = convention as Record; + } + if (rule === undefined || typeof rule.style !== 'string') return undefined; + const param = rule.style === 'cursor' ? rule.cursorParam : rule.offsetParam; + return { + style: rule.style, + ...(typeof param === 'string' ? { param } : {}), + ...(typeof rule.nextCursor === 'string' ? { nextCursor: rule.nextCursor } : {}), + ...(typeof rule.hasMore === 'string' ? { hasMore: rule.hasMore } : {}), + ...(typeof rule.limitParam === 'string' ? { limitParam: rule.limitParam } : {}), + ...(typeof rule.items === 'string' ? { items: rule.items } : {}), + }; +} diff --git a/packages/client-generator/src/authoring/printer.ts b/packages/client-generator/src/authoring/printer.ts new file mode 100644 index 0000000000..927883015a --- /dev/null +++ b/packages/client-generator/src/authoring/printer.ts @@ -0,0 +1,39 @@ +// A small indentation-aware text builder for emitting code in ANY language — +// deliberately not an AST. Part of the language-neutral authoring toolkit. + +export class Printer { + private readonly lines: string[] = []; + private depth = 0; + + constructor(private readonly indentUnit: string = ' ') {} + + /** Append one line at the current depth; no argument appends an empty line. */ + line(text = ''): this { + this.lines.push(text === '' ? '' : this.indentUnit.repeat(this.depth) + text); + return this; + } + + blank(): this { + return this.line(); + } + + /** Run `body` with the depth increased by one. */ + indent(body: () => void): this { + this.depth++; + body(); + this.depth--; + return this; + } + + /** `open` at the current depth, `body` indented, `close` back at the current depth. + * Omit `close` for languages whose blocks end by dedent alone (Python, YAML). */ + block(open: string, body: () => void, close?: string): this { + this.line(open); + this.indent(body); + return close === undefined ? this : this.line(close); + } + + toString(): string { + return this.lines.join('\n') + '\n'; + } +} diff --git a/packages/client-generator/src/authoring/reference-page.ts b/packages/client-generator/src/authoring/reference-page.ts new file mode 100644 index 0000000000..41827d4853 --- /dev/null +++ b/packages/client-generator/src/authoring/reference-page.ts @@ -0,0 +1,211 @@ +// The reference-page renderer: the Markdown reference for ONE generated SDK, built from +// the IR that SDK is built from, plus that generator's own `sample` hook for the call +// snippets. It writes no call syntax of its own — a second spelling of the SDK would +// drift from it the first time either side changed. Part of the authoring toolkit, so a +// generator ejected as source (python, go, php) reaches it the same way we do. + +import type { + ApiModel, + OperationModel, + ParamModel, + SchemaModel, +} from '../intermediate-representation/model.js'; +import { paginationRuleFor } from './pagination.js'; +import { Printer } from './printer.js'; + +/** What a generator knows about its language that the IR cannot tell the renderer. */ +export type ReferenceLanguage = { + /** Generator name; also the infix of the page file (`.python.md`). */ + name: string; + /** Display name for the default heading. */ + label: string; + /** Fence language for the call samples. */ + fence: string; + /** What the SDK needs at run time, as one sentence. */ + requires: string; +}; + +export type ReferencePageOptions = { + /** Page heading. */ + title: string; + /** Emit YAML front matter carrying the title, for docs sites that expect it. */ + frontmatter: boolean; + language: ReferenceLanguage; + /** The call snippet for one operation — the generator's own `sample` hook. */ + sample: (operation: OperationModel) => { lang: string; source: string } | undefined; + /** The `pagination` config, passed through to `paginationRuleFor`. */ + pagination?: Record; +}; + +/** Table-cell-safe text: one line, with pipes and backslashes escaped. */ +function cell(text: string | undefined): string { + return (text ?? '').replace(/\s+/g, ' ').trim().replace(/\\/g, '\\\\').replace(/\|/g, '\\|'); +} + +/** A wire-level type name for a schema — the vocabulary of the description, not of a language. */ +function typeLabel(schema: SchemaModel): string { + switch (schema.kind) { + case 'ref': + return schema.name; + case 'omit': + return schema.base; + case 'scalar': + return schema.scalar; + case 'array': + return `array of ${typeLabel(schema.items)}`; + case 'record': + return `map of ${typeLabel(schema.value)}`; + case 'enum': { + const values = schema.values.map(String); + const shown = values.slice(0, 6).join(', '); + return values.length > 6 ? `enum: ${shown}, and ${values.length - 6} more` : `enum: ${shown}`; + } + case 'literal': + return String(schema.value); + case 'union': + return schema.members.map(typeLabel).join(' or '); + case 'intersection': + return schema.members.map(typeLabel).join(' and '); + case 'object': + return 'object'; + case 'null': + return 'null'; + case 'unknown': + return 'any'; + } +} + +/** Binary success content with no JSON alternative — the same test the clients apply. */ +function isBinary(op: OperationModel): boolean { + if (op.successResponses.some((response) => response.contentType.toLowerCase().includes('json'))) { + return false; + } + return op.successResponses.some( + (response) => + response.contentType.startsWith('image/') || + response.contentType === 'application/octet-stream' + ); +} + +function writeParameterTable(printer: Printer, params: ParamModel[]): void { + printer.line('| Parameter | In | Type | Required | Description |'); + printer.line('| --------- | -- | ---- | -------- | ----------- |'); + for (const param of params) { + printer.line( + `| \`${param.name}\` | ${param.in} | ${cell(typeLabel(param.schema))} | ${ + param.required ? 'yes' : 'no' + } | ${cell(param.description)} |` + ); + } + printer.blank(); +} + +function writeOperation(printer: Printer, op: OperationModel, options: ReferencePageOptions): void { + printer.line(`### \`${op.specName ?? op.name}\``); + printer.blank(); + if (op.summary !== undefined) { + printer.line(cell(op.summary)); + printer.blank(); + } + printer.line(`\`${op.method.toUpperCase()} ${op.path}\``); + printer.blank(); + + const sample = options.sample(op); + if (sample !== undefined) { + printer.line('```' + options.language.fence); + for (const line of sample.source.replace(/\n+$/, '').split('\n')) printer.line(line); + printer.line('```'); + printer.blank(); + } + + const params = [...op.pathParams, ...op.queryParams, ...op.headerParams]; + if (params.length > 0) writeParameterTable(printer, params); + + if (op.requestBody !== undefined) { + printer.line( + `Body: \`${op.requestBody.contentType}\`${op.requestBody.required ? ', required' : ', optional'}, of type ${typeLabel(op.requestBody.schema)}.` + ); + } + const success = op.successResponses[0]; + printer.line( + success === undefined + ? 'Returns no content.' + : `Returns \`${success.contentType}\`, of type ${typeLabel(success.schema)}.` + ); + // 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)) { + 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')) { + printer.line('This operation streams server-sent events, so the SDK iterates the events.'); + } + if (isBinary(op)) { + printer.line('This operation returns binary content.'); + } + printer.blank(); +} + +/** The whole page: heading, requirements, security schemes, then every operation by tag. */ +export function renderReferencePage(model: ApiModel, options: ReferencePageOptions): string { + const printer = new Printer(); + if (options.frontmatter) { + printer.line('---'); + printer.line(`title: ${options.title}`); + printer.line('---'); + printer.blank(); + } + printer.line(`# ${options.title}`); + printer.blank(); + printer.line( + `Generated reference for the ${options.language.label} SDK, produced from the API description by \`redocly generate-client\`.` + ); + printer.line('Re-run generation to update it — this file is not hand-edited.'); + printer.blank(); + printer.line(options.language.requires); + printer.blank(); + + printer.line('## Authentication'); + printer.blank(); + if (model.securitySchemes.length === 0) { + printer.line('The description declares no security schemes.'); + } else { + printer.line('The description declares these schemes, which you pass to the client:'); + printer.blank(); + printer.line('| Scheme | Kind | Sent as |'); + printer.line('| ------ | ---- | ------- |'); + for (const scheme of model.securitySchemes) { + const sentAs = + scheme.kind === 'bearer' + ? '`Authorization: Bearer `' + : scheme.kind === 'basic' + ? '`Authorization: Basic `' + : scheme.kind === 'apiKeyHeader' + ? `the \`${scheme.headerName}\` header` + : scheme.kind === 'apiKeyQuery' + ? `the \`${scheme.paramName}\` query parameter` + : `the \`${scheme.cookieName}\` cookie`; + printer.line(`| \`${scheme.key}\` | ${scheme.kind} | ${sentAs} |`); + } + } + printer.blank(); + + // One section per tag, in the order the description declares them, then the untagged + // operations — the same grouping the CLI and the split output modes use. + const operations = model.services.flatMap((service) => service.operations); + const groups = [...new Set(operations.map((op) => op.tags[0]))]; + for (const group of groups) { + printer.line(group === undefined ? '## Operations' : `## ${group}`); + printer.blank(); + for (const op of operations.filter((candidate) => candidate.tags[0] === group)) { + writeOperation(printer, op, options); + } + } + return ( + printer + .toString() + .replace(/\n{3,}/g, '\n\n') + .trimEnd() + '\n' + ); +} diff --git a/packages/client-generator/src/authoring/schema.ts b/packages/client-generator/src/authoring/schema.ts new file mode 100644 index 0000000000..4dbe4b436c --- /dev/null +++ b/packages/client-generator/src/authoring/schema.ts @@ -0,0 +1,194 @@ +// Language-neutral schema helpers: the cross-language variance points (allOf, +// discriminators, nullability, enums) exposed as pure functions over the IR, so +// a generator in ANY output language never re-implements schema semantics. + +import type { + ApiModel, + NamedSchemaModel, + PropertyModel, + SchemaModel, +} from '../intermediate-representation/model.js'; +import { casing } 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 { + 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((s) => s.name === name); + if (named === undefined) return undefined; + current = named.schema; + } + return current; +} + +/** + * The flattened view of an object or `allOf` composition — what every language + * without intersection types renders. Later members win on property-name + * conflicts (allOf refinement); returns undefined when a member is not an + * object (nothing coherent to flatten). + */ +export function flattenAllOf( + schema: SchemaModel, + model: ApiModel +): { properties: PropertyModel[]; description?: string } | undefined { + const resolved = deref(schema, model); + if (resolved === undefined) return undefined; + if (resolved.kind === 'object') { + return { properties: resolved.properties, description: resolved.description }; + } + if (resolved.kind !== 'intersection') return undefined; + const merged = new Map(); + for (const member of resolved.members) { + const flat = flattenAllOf(member, model); + if (flat === undefined) return undefined; + for (const property of flat.properties) merged.set(property.name, property); + } + return { properties: [...merged.values()], description: resolved.description }; +} + +/** The neutral discriminator dispatch table; each language renders its own idiom from it. */ +export function discriminatorCases( + schema: SchemaModel, + model: ApiModel +): + | { property: string; cases: Array<{ value: string; schemaName: string; schema: SchemaModel }> } + | undefined { + const resolved = deref(schema, model); + if (resolved?.kind !== 'union' || resolved.discriminator === undefined) return undefined; + const cases = []; + for (const { value, schemaName } of resolved.discriminator.mapping) { + const target = deref({ kind: 'ref', name: schemaName }, model); + if (target === undefined) return undefined; + cases.push({ value, schemaName, schema: target }); + } + return { property: resolved.discriminator.propertyName, cases }; +} + +export function isNullable(schema: SchemaModel): boolean { + return schema.kind === 'union' && schema.members.some((member) => member.kind === 'null'); +} + +/** The schema without its `null` union members (a single survivor is unwrapped). */ +export function unwrapNullable(schema: SchemaModel): SchemaModel { + if (schema.kind !== 'union' || !isNullable(schema)) return schema; + const rest = schema.members.filter((member) => member.kind !== 'null'); + return rest.length === 1 ? rest[0] : { ...schema, members: rest }; +} + +/** Enum values plus language-safe SCREAMING_SNAKE member-name suggestions. */ +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()}` + ); + return { values: schema.values, scalar: schema.scalar, memberNames }; +} + +/** Description text as trimmed lines ready for any comment syntax; blank edges dropped. */ +export function docText(description?: string): string[] { + if (!description) return []; + const lines = description.split(/\r\n|\n|\r/).map((line) => line.trim()); + while (lines.length > 0 && lines[0] === '') lines.shift(); + while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + return lines; +} + +/** One pointer step over a (dereferenced) schema; an intersection takes the LAST member that resolves, since later `allOf` members refine earlier ones. */ +function stepIntoSchema( + schema: SchemaModel, + key: string, + model: ApiModel +): SchemaModel | undefined { + if (schema.kind === 'object') return schema.properties.find((p) => p.name === key)?.schema; + if (schema.kind === 'record') return schema.value; + if (schema.kind === 'array' && /^(0|[1-9]\d*)$/.test(key)) return schema.items; + if (schema.kind === 'intersection') { + let match: SchemaModel | undefined; + for (const member of schema.members) { + const target = deref(member, model); + if (target === undefined) continue; + match = stepIntoSchema(target, key, model) ?? match; + } + return match; + } + return undefined; +} + +/** + * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) over a schema, walking the + * VALUE shape it describes: object property steps by name, record values for any token, + * array items for numeric tokens, with `ref` steps resolved through the model's named + * schemas (cycle-guarded) and intersections (`allOf`) resolved across their members. + * Unions bail (genuinely ambiguous). Returns `undefined` on any miss. + */ +export function schemaAtPointer( + schema: SchemaModel, + pointer: string, + model: ApiModel +): SchemaModel | undefined { + let current = deref(schema, model); + if (current === undefined || (pointer !== '' && !pointer.startsWith('/'))) return undefined; + if (pointer === '') return current; + for (const token of pointer.slice(1).split('/')) { + const key = token.replaceAll('~1', '/').replaceAll('~0', '~'); + const next = stepIntoSchema(current, key, model); + if (next === undefined) return undefined; + current = deref(next, model); + if (current === undefined) return undefined; + } + return current; +} + +/** + * The wire-coerce hint for a response HEADER schema: `'integer'` / `'number'` / + * `'boolean'` for scalar-ish leaves, `'string'` for everything else (headers are + * strings on the wire; complex schemas have no sensible coercion). Resolves `ref`s + * through the model, peels nullable unions and constraint-only `allOf` members. + */ +export function headerCoerceType( + schema: SchemaModel, + model: { schemas: readonly NamedSchemaModel[] }, + seen: Set = new Set() +): 'string' | 'number' | 'integer' | 'boolean' { + if (schema.kind === 'ref') { + if (seen.has(schema.name)) return 'string'; + seen.add(schema.name); + const named = model.schemas.find((entry) => entry.name === schema.name); + if (named === undefined) return 'string'; + return headerCoerceType(named.schema, model, seen); + } + if (schema.kind === 'intersection') { + const members = schema.members.filter((member) => member.kind !== 'unknown'); + if (members.length === 1) return headerCoerceType(members[0], model, seen); + const types = [ + ...new Set(members.map((member) => headerCoerceType(member, model, new Set(seen)))), + ]; + if (types.length === 1) return types[0]; + // An integer member refined by a number bound (or vice versa) stays numeric. + if (types.every((type) => type === 'integer' || type === 'number')) return 'number'; + return 'string'; + } + if (schema.kind === 'union') { + const members = schema.members.filter((member) => member.kind !== 'null'); + if (members.length === 1) return headerCoerceType(members[0], model, seen); + return 'string'; + } + if (schema.kind === 'scalar' || schema.kind === 'enum') { + if (schema.scalar === 'integer') return 'integer'; + if (schema.scalar === 'number') return 'number'; + if (schema.scalar === 'boolean') return 'boolean'; + } + if (schema.kind === 'literal') { + if (typeof schema.value === 'number') { + return Number.isInteger(schema.value) ? 'integer' : 'number'; + } + if (typeof schema.value === 'boolean') return 'boolean'; + } + return 'string'; +} diff --git a/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap b/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap index 420750eb85..2160d2aa2c 100644 --- a/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap +++ b/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap @@ -8,7 +8,7 @@ exports[`emitClientSingleFile (package arm) > matches the golden output for a sm * T (v1.0.0) */ -import { createClient, type EnvelopeResult, type OperationDescriptor, type RequestOptions, type SseOptions } from '@redocly/client-generator'; +import { createClient, type OperationDescriptor } from '@redocly/client-generator'; export type Order = { id: string; @@ -22,13 +22,17 @@ export type OrderEvent = {}; export type GetOrderResult = Order; -export type GetOrderParams = { +export type GetOrderPath = { + orderId: string; +}; + +export type GetOrderQuery = { expand?: string; }; export type GetOrderVariables = { - orderId: string; - params?: GetOrderParams; + path: GetOrderPath; + query?: GetOrderQuery; }; /** @@ -38,8 +42,8 @@ export type GetOrderVariables = { export type Ops = { getOrder: { args: { - orderId: string; - params?: GetOrderParams; + path: GetOrderPath; + query?: GetOrderQuery; }; result: GetOrderResult; }; @@ -71,11 +75,7 @@ export type OperationTag = Extract<(typeof OPERATIONS)[keyof typeof OPERATIONS], export const client = createClient(OPERATIONS, { serverUrl: "https://cafe.example.com", clientHeader: "redocly-client-generator" }); export const { configure, use } = client; -export const setBearer = client.auth.bearer; -export const getOrder = (orderId: string, params: { - expand?: string; -} = {}, init?: I): Promise, I>> => client.getOrder({ orderId, params }, init) as Promise, I>>; -export const streamEvents = (init: SseOptions = {}) => client.streamEvents({}, init); +export const { getOrder, streamEvents } = client; export { ApiError, createClient, defaultRetryOn, TimeoutError } from '@redocly/client-generator'; export type { ClientConfig, Envelope, Middleware, RequestOptions, ServerSentEvent, SseOptions } from '@redocly/client-generator'; @@ -90,7 +90,7 @@ exports[`emitClientSingleFile — pagination > matches the golden output for a p * T (v1.0.0) */ -import { createClient, type EnvelopeResult, type OperationDescriptor, type RequestOptions } from '@redocly/client-generator'; +import { createClient, type OperationDescriptor } from '@redocly/client-generator'; export type Order = {}; @@ -107,24 +107,28 @@ export type OrderPage = { export type ListOrdersResult = OrderPage; -export type ListOrdersParams = { +export type ListOrdersQuery = { cursor?: string; limit?: string; }; export type ListOrdersVariables = { - params?: ListOrdersParams; + query?: ListOrdersQuery; }; export type GetOrderResult = Order; -export type GetOrderParams = { +export type GetOrderPath = { + orderId: string; +}; + +export type GetOrderQuery = { expand?: string; }; export type GetOrderVariables = { - orderId: string; - params?: GetOrderParams; + path: GetOrderPath; + query?: GetOrderQuery; }; /** @@ -134,15 +138,15 @@ export type GetOrderVariables = { export type Ops = { listOrders: { args: { - params?: ListOrdersParams; + query?: ListOrdersQuery; }; result: ListOrdersResult; item: Order; }; getOrder: { args: { - orderId: string; - params?: GetOrderParams; + path: GetOrderPath; + query?: GetOrderQuery; }; result: GetOrderResult; }; @@ -169,13 +173,7 @@ export type OperationTag = Extract<(typeof OPERATIONS)[keyof typeof OPERATIONS], export const client = createClient(OPERATIONS, { serverUrl: "https://api.example.com", clientHeader: "redocly-client-generator" }); export const { configure, use } = client; -export const listOrders = Object.assign((params: { - cursor?: string; - limit?: string; -} = {}, init?: I): Promise, I>> => client.listOrders({ params }, init) as Promise, I>>, { pages: client.listOrders.pages, items: client.listOrders.items }); -export const getOrder = (orderId: string, params: { - expand?: string; -} = {}, init?: I): Promise, I>> => client.getOrder({ orderId, params }, init) as Promise, I>>; +export const { listOrders, getOrder } = client; export { ApiError, createClient, defaultRetryOn, TimeoutError } from '@redocly/client-generator'; export type { ClientConfig, Envelope, Middleware, RequestOptions } from '@redocly/client-generator'; @@ -190,7 +188,7 @@ exports[`emitClientSingleFile — pagination > matches the golden output for a r * T (v1.0.0) */ -import { createClient, type OperationDescriptor, type RequestOptions, type Result } from '@redocly/client-generator'; +import { createClient, type OperationDescriptor, type Result } from '@redocly/client-generator'; export type Order = {}; @@ -207,26 +205,30 @@ export type OrderPage = { export type ListOrdersResult = OrderPage; -export type ListOrdersParams = { +export type ListOrdersQuery = { cursor?: string; limit?: string; }; export type ListOrdersVariables = { - params?: ListOrdersParams; + query?: ListOrdersQuery; }; export type GetOrderResult = Order; export type GetOrderError = Problem; -export type GetOrderParams = { +export type GetOrderPath = { + orderId: string; +}; + +export type GetOrderQuery = { expand?: string; }; export type GetOrderVariables = { - orderId: string; - params?: GetOrderParams; + path: GetOrderPath; + query?: GetOrderQuery; }; /** @@ -236,7 +238,7 @@ export type GetOrderVariables = { export type Ops = { listOrders: { args: { - params?: ListOrdersParams; + query?: ListOrdersQuery; }; result: Result; mode: "result"; @@ -245,8 +247,8 @@ export type Ops = { }; getOrder: { args: { - orderId: string; - params?: GetOrderParams; + path: GetOrderPath; + query?: GetOrderQuery; }; result: Result; mode: "result"; @@ -274,13 +276,7 @@ export type OperationTag = Extract<(typeof OPERATIONS)[keyof typeof OPERATIONS], export const client = createClient(OPERATIONS, { serverUrl: "https://api.example.com", errorMode: "result", clientHeader: "redocly-client-generator" }); export const { configure, use } = client; -export const listOrders = Object.assign((params: { - cursor?: string; - limit?: string; -} = {}, init: RequestOptions = {}) => client.listOrders({ params }, init), { pages: client.listOrders.pages, items: client.listOrders.items }); -export const getOrder = (orderId: string, params: { - expand?: string; -} = {}, init: RequestOptions = {}) => client.getOrder({ orderId, params }, init); +export const { listOrders, getOrder } = client; export { ApiError, createClient, defaultRetryOn, TimeoutError } from '@redocly/client-generator'; export type { ClientConfig, Envelope, Middleware, RequestOptions, Result } from '@redocly/client-generator'; diff --git a/packages/client-generator/src/emitters/__tests__/__snapshots__/ts-literal.test.ts.snap b/packages/client-generator/src/emitters/__tests__/__snapshots__/ts-literal.test.ts.snap new file mode 100644 index 0000000000..a40995353c --- /dev/null +++ b/packages/client-generator/src/emitters/__tests__/__snapshots__/ts-literal.test.ts.snap @@ -0,0 +1,29 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`codeLiteral > array 1`] = `"["a", 1, false]"`; + +exports[`codeLiteral > booleans 1`] = `"true"`; + +exports[`codeLiteral > empty array 1`] = `"[]"`; + +exports[`codeLiteral > empty object 1`] = `"{}"`; + +exports[`codeLiteral > flat object 1`] = `"{ id: "getPet", method: "GET", count: 2 }"`; + +exports[`codeLiteral > negative number 1`] = `"-3.5"`; + +exports[`codeLiteral > nested descriptor-like shape 1`] = `"{ 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" } }"`; + +exports[`codeLiteral > non-identifier key is quoted 1`] = `"{ "X-Request-Id": "header", "a-b": 1 }"`; + +exports[`codeLiteral > null 1`] = `"null"`; + +exports[`codeLiteral > number 1`] = `"42"`; + +exports[`codeLiteral > reserved-word key stays bare 1`] = `"{ in: "query", name: "limit" }"`; + +exports[`codeLiteral > string 1`] = `""plain""`; + +exports[`codeLiteral > string with newline 1`] = `""a\\nb""`; + +exports[`codeLiteral > string with quotes and backslashes 1`] = `""say \\"hi\\" \\\\ done""`; diff --git a/packages/client-generator/src/emitters/__tests__/auth.test.ts b/packages/client-generator/src/emitters/__tests__/auth.test.ts deleted file mode 100644 index 8c89800339..0000000000 --- a/packages/client-generator/src/emitters/__tests__/auth.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { SecuritySchemeModel } from '../../intermediate-representation/model.js'; -import { apiKeySetterName, authSetterNames } from '../auth.js'; - -/** A spec exercising all five injectable kinds at once. */ -const allKinds: SecuritySchemeModel[] = [ - { kind: 'bearer', key: 'OAuth2' }, - { kind: 'basic', key: 'Basic' }, - { kind: 'apiKeyHeader', key: 'HeaderKey', headerName: 'X-API-Key' }, - { kind: 'apiKeyQuery', key: 'QueryKey', paramName: 'api_key' }, - { kind: 'apiKeyCookie', key: 'CookieKey', cookieName: 'sid' }, -]; - -describe('apiKeySetterName', () => { - it('is the bare setApiKey for a sole apiKey scheme', () => { - expect(apiKeySetterName('anything', true)).toBe('setApiKey'); - }); - - it('suffixes the PascalCased scheme key when several apiKey schemes exist', () => { - expect(apiKeySetterName('cookieAuth', false)).toBe('setApiKeyCookieAuth'); - expect(apiKeySetterName('QueryKey', false)).toBe('setApiKeyQueryKey'); - }); -}); - -describe('authSetterNames', () => { - it('returns no names when there are no schemes', () => { - expect(authSetterNames([])).toEqual([]); - }); - - it('emits setBearer once for any number of bearer schemes', () => { - expect( - authSetterNames([ - { kind: 'bearer', key: 'OAuth2' }, - { kind: 'bearer', key: 'BearerHttp' }, - ]) - ).toEqual(['setBearer']); - }); - - it('emits setBasicAuth for basic schemes', () => { - expect(authSetterNames([{ kind: 'basic', key: 'Basic' }])).toEqual(['setBasicAuth']); - }); - - it('names a sole apiKey scheme setApiKey regardless of its `in`', () => { - expect( - authSetterNames([{ kind: 'apiKeyCookie', key: 'CookieKey', cookieName: 'sid' }]) - ).toEqual(['setApiKey']); - }); - - it('disambiguates several apiKey schemes with the PascalCased key', () => { - expect( - authSetterNames([ - { kind: 'apiKeyHeader', key: 'HeaderKey', headerName: 'X-API-Key' }, - { kind: 'apiKeyQuery', key: 'QueryKey', paramName: 'api_key' }, - ]) - ).toEqual(['setApiKeyHeaderKey', 'setApiKeyQueryKey']); - }); - - it('orders the full surface bearer → basic → apiKey (emission order)', () => { - expect(authSetterNames(allKinds)).toEqual([ - 'setBearer', - 'setBasicAuth', - 'setApiKeyHeaderKey', - 'setApiKeyQueryKey', - 'setApiKeyCookieKey', - ]); - }); -}); diff --git a/packages/client-generator/src/emitters/__tests__/cli.test.ts b/packages/client-generator/src/emitters/__tests__/cli.test.ts new file mode 100644 index 0000000000..2c390ba08d --- /dev/null +++ b/packages/client-generator/src/emitters/__tests__/cli.test.ts @@ -0,0 +1,330 @@ +import { logger } from '@redocly/openapi-core'; + +import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; +import { commandData, renderCliModule, renderComposedCliEntry } from '../cli.js'; + +const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' }; +const INT: SchemaModel = { kind: 'scalar', scalar: 'integer' }; + +const MODEL: ApiModel = { + title: 'Cafe', + version: '1.0.0', + serverUrl: 'https://api.cafe.example', + services: [ + { + name: 'Orders', + operations: [ + { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + summary: 'List orders.', + tags: ['Orders'], + pathParams: [], + queryParams: [ + { + name: 'status', + in: 'query', + required: false, + schema: { kind: 'enum', values: ['open', 'closed'], scalar: 'string' }, + }, + { name: 'pageSize', in: 'query', required: false, schema: INT }, + { + name: 'tag', + in: 'query', + required: false, + schema: { kind: 'array', items: STRING }, + }, + { name: 'cursor', in: 'query', required: false, schema: STRING }, + ], + headerParams: [], + cookieParams: [], + security: [], + paginationExtension: { + style: 'cursor', + cursorParam: 'cursor', + nextCursor: '/next', + items: '/items', + }, + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'OrderPage' }, + }, + ], + errorResponses: [], + }, + { + name: 'getOrder', + specName: 'getOrder', + method: 'get', + path: '/orders/{orderId}', + tags: ['Orders'], + pathParams: [{ name: 'orderId', in: 'path', required: true, schema: STRING }], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + { + name: 'createOrder', + specName: 'createOrder', + method: 'post', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + requestBody: { + contentType: 'application/json', + required: true, + schema: { kind: 'ref', name: 'Order' }, + }, + successResponses: [ + { + status: '201', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + { + name: 'streamEvents', + specName: 'streamEvents', + method: 'get', + path: '/events', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'text/event-stream', + schema: { kind: 'object', properties: [] }, + }, + ], + errorResponses: [], + }, + { + name: 'downloadReport', + specName: 'downloadReport', + method: 'get', + path: '/report', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { status: '200', contentType: 'application/octet-stream', schema: { kind: 'unknown' } }, + ], + errorResponses: [], + }, + ], + }, + { + name: 'Default', + operations: [ + { + name: 'ping', + specName: 'ping', + method: 'get', + path: '/ping', + tags: [], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [], + errorResponses: [], + }, + ], + }, + ], + schemas: [ + { + name: 'Order', + schema: { kind: 'object', properties: [{ name: 'id', schema: STRING, required: true }] }, + }, + { + name: 'OrderPage', + schema: { + kind: 'object', + properties: [ + { + name: 'items', + schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, + required: true, + }, + { name: 'next', schema: STRING, required: false }, + ], + }, + }, + ], + securitySchemes: [{ key: 'BearerAuth', kind: 'bearer' }], +} as unknown as ApiModel; + +describe('commandData', () => { + it('derives groups from tags, flags from query params, and positionals in path order', () => { + const commands = commandData(MODEL, {}); + const list = commands.find((command) => command.name === 'listOrders'); + expect(list).toMatchObject({ + group: 'Orders', + summary: 'List orders.', + paginated: true, + flags: [ + { name: 'status', param: 'status', type: 'string', enum: ['open', 'closed'] }, + { name: 'page-size', param: 'pageSize', type: 'number' }, + { name: 'tag', param: 'tag', type: 'array' }, + { name: 'cursor', param: 'cursor', type: 'string' }, + ], + }); + expect(commands.find((command) => command.name === 'getOrder')).toMatchObject({ + positionals: [{ name: 'orderId' }], + }); + // Untagged operations are flat: no group. + expect(commands.find((command) => command.name === 'ping')?.group).toBeUndefined(); + }); + + it('marks bodies, SSE, and blob operations, and stores IR schemas verbatim', () => { + const commands = commandData(MODEL, {}); + expect(commands.find((command) => command.name === 'createOrder')).toMatchObject({ + body: { required: true }, + schemas: { + request: { kind: 'ref', name: 'Order' }, + response: { kind: 'ref', name: 'Order' }, + }, + }); + expect(commands.find((command) => command.name === 'streamEvents')?.sse).toBe(true); + expect(commands.find((command) => command.name === 'downloadReport')?.blob).toBe(true); + }); +}); + +describe('renderCliModule', () => { + const options = { + stem: 'client', + importExt: 'js', + runtime: 'inline' as const, + zodSelected: false, + }; + + 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); + expect(out).toContain('function parseInvocation'); // embedded runtime + expect(out).toContain('import { client, configure } from "./client.js";'); + expect(out).toContain('schemes: [{"key":"BearerAuth","kind":"bearer"}]'); + // A library as well as a binary: the exports composition imports, and an entry + // guard so importing the module never executes the CLI. + expect(out).toContain('export const COMMANDS: CliCommand[]'); + expect(out).toContain('export const wiring: CliWiring'); + expect(out).toContain('export const run ='); + expect(out).toContain( + 'realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1])' + ); + 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'); + expect(out).toContain('import { zodValidation } from "./client.zod.js";'); + expect(out).toContain( + 'use(zodValidation(process.argv.includes("--dry-run") ? { response: false } : {}));' + ); + }); + + /** `orders` is the slug of the `Orders` tag, so an operation of that name collides. */ + function modelWithOperationNamedOrders(tags: string[]): ApiModel { + const [service] = MODEL.services; + return { + ...MODEL, + services: [ + { + ...service, + operations: service.operations.map((op) => + op.name === 'getOrder' ? { ...op, name: 'orders', tags } : op + ), + }, + ], + }; + } + + it('warns when an operation is named after a tag, naming how it resolves', () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + + renderCliModule(modelWithOperationNamedOrders(['Reports']), options); + expect(warn.mock.lastCall?.[0]).toContain('orders (run it as "reports orders")'); + + renderCliModule(modelWithOperationNamedOrders([]), options); + expect(warn.mock.lastCall?.[0]).toContain('orders (keeps the bare word'); + + renderCliModule(MODEL, options); + expect(warn).toHaveBeenCalledTimes(2); + warn.mockRestore(); + }); +}); + +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( + [ + { alias: '2fa-api', modulePath: './2fa.cli.js' }, + { alias: 'my-api', modulePath: './my-api.cli.js' }, + { alias: 'my.api', modulePath: './my-api-2.cli.js' }, + ], + 'cafe' + ); + expect(out).toContain('import { COMMANDS as _2fa_apiCommands'); + expect(out).toContain('COMMANDS as my_apiCommands'); + expect(out).toContain('COMMANDS as my_api_2Commands'); + expect(out).toContain('namespace: "2fa-api"'); + }); +}); diff --git a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts b/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts index 7964d0bd5b..c08fcdfdf7 100644 --- a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts +++ b/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts @@ -1,7 +1,8 @@ +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 { ts } from '../ts.js'; import { modelWith, namedSchema, operation, param, response, SCALAR } from './fixtures.js'; /** The package arm of the shared emitter. */ @@ -90,8 +91,10 @@ describe('emitClientSingleFile (package arm)', () => { 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 EnvelopeResult, type OperationDescriptor, type RequestOptions, type SseOptions, type TokenProvider } from '@redocly/client-generator';" + "import { createClient, type OperationDescriptor } from '@redocly/client-generator';" ); expect(output).not.toContain('__send'); expect(output).not.toContain('__buildUrl'); @@ -107,7 +110,6 @@ describe('emitClientSingleFile (package arm)', () => { { serverUrl: 'https://x/\u2029path' } ); expect(out).toContain('serverUrl: "https://x/\\u2029path"'); - expect(out).toContain('client.auth.apiKey("k\\u2028evil", value)'); expect(out).not.toContain('\u2028'); expect(out).not.toContain('\u2029'); }); @@ -149,39 +151,33 @@ describe('emitClientSingleFile (package arm)', () => { expect(emit(model)).toContain('export function isCat('); }); - it('emits core destructure and auth sugar bound to the instance', () => { + it('exports the core destructure, and no per-scheme credential setters', () => { expect(output).toContain('export const { configure, use } = client;'); - expect(output).toContain('export const setBearer = client.auth.bearer;'); - // Sole apiKey scheme → unsuffixed setter, scheme key baked into the closure. - expect(output).toContain( - 'export const setApiKey = (value: TokenProvider) => client.auth.apiKey("cookieAuth", value);' - ); - expect(output).not.toContain('setBasicAuth'); - }); - - it('emits flat sugar one-liners forwarding to the grouped client methods', () => { - // Throw-mode flat sugar is generic over `init` so `{ envelope: true }` narrows. - expect(output).toContain( - 'export const getOrder = (orderId: string, params: {' - ); - expect(output).toContain('=> client.getOrder({ orderId, params }, init) as Promise<'); - expect(output).toContain( - 'export const createPet = (body: Pet, init?: I): Promise, I>'); - // SSE sugar takes SseOptions and returns the generator directly (no envelope). + // Credentials are set through `configure({ auth })` or `client.auth.*`. A setter per + // scheme gave the same act a third spelling and a name operations had to avoid. + expect(output).not.toContain('export const setBearer'); + expect(output).not.toContain('export const setApiKey'); + expect(output).not.toContain('export const setBasicAuth'); + // What tells the runtime which credentials an operation needs is the descriptor. + expect(output).toContain('security: [[{ scheme: "bearerAuth"'); + }); + + it('exports the client methods as bindings — one function per operation, no wrappers', () => { + // The module-level name IS the method, so importing it and reaching through the + // instance can never disagree about the arguments. expect(output).toContain( - 'export const streamEvents = (init: SseOptions = {}) => client.streamEvents({}, init);' + 'export const { getOrder, createPet, upload, streamEvents, configure_2 } = client;' ); + expect(output).not.toContain('=> client.getOrder('); + expect(output).not.toContain('=> client.streamEvents('); }); it('renames the colliding operation everywhere while the core members keep their names', () => { expect(output).toContain('configure_2: {'); expect(output).toContain('id: "configure"'); // descriptor id stays the spec operationId - expect(output).toContain( - 'export const configure_2 = (init?: I): Promise client.configure_2({}, init) as Promise<'); + // `configure` itself stays the client's own member; the operation rides the binding. + expect(output).toContain('export const { configure, use } = client;'); + expect(output).toContain('configure_2 } = client;'); }); it('re-exports the public surface', () => { @@ -193,7 +189,7 @@ describe('emitClientSingleFile (package arm)', () => { ); }); - it('keys flat-sugar path values by WIRE name when it differs from the ident', () => { + it('keys a path value by its WIRE name, which is what the runtime substitutes', () => { const model = modelWith([ operation({ name: 'getPet', @@ -204,11 +200,9 @@ describe('emitClientSingleFile (package arm)', () => { ]); // No options at all — the emitter's own defaults apply. const out = emit(model); - expect(out).toContain( - 'export const getPet = (pet_id: string, init?: I): Promise client.getPet({ "pet-id": pet_id }, init) as Promise<'); - expect(out).toContain('"pet-id": string;'); // Ops args + Variables alias, wire-keyed + expect(out).toContain('export type GetPetPath = {\n "pet-id": string;\n};'); + expect(out).toContain('path: GetPetPath;'); + expect(out).not.toContain('pet_id'); }); it('keeps sanitizer-collapsed path params distinct: identifier-safe wire name, renamed ident', () => { @@ -220,9 +214,11 @@ describe('emitClientSingleFile (package arm)', () => { successResponses: [response()], }), ]); - // `a-b` sanitizes to `a_b`, so the literal `a_b` param is deduped to `a_b_2` — - // but both forward under their wire names. - expect(emit(model)).toContain('client.compare({ "a-b": a_b, a_b: a_b_2 }, init) as Promise<'); + // Two wire names that sanitize alike stay distinct, because the layer keys them by + // wire name and never derives a binding identifier. + expect(emit(model)).toContain( + 'export type ComparePath = {\n "a-b": string;\n a_b: string;\n};' + ); }); it('layers a baked setup OVER the spec defaults and imports the contract types', () => { @@ -231,7 +227,7 @@ describe('emitClientSingleFile (package arm)', () => { setup: '{ config: { retry: { retries: 2 } } }', }); expect(out).toContain( - "import { createClient, mergeSetup, type ClientConfig, type EnvelopeResult, type Middleware, type OperationDescriptor, type RequestOptions } from '@redocly/client-generator';" + "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 } } };' @@ -264,16 +260,15 @@ describe('emitClientSingleFile (package arm)', () => { expect(out).toContain('type Result'); }); - it('grouped argsStyle destructures the client methods instead of flat one-liners', () => { - const out = emit(CAFE, { serverUrl: 'https://x', argsStyle: 'grouped' }); + it('argsStyle: flat merges the inputs and tells the runtime, keeping one binding', () => { + const out = emit(CAFE, { serverUrl: 'https://x', argsStyle: 'flat' }); expect(out).toContain( 'export const { getOrder, createPet, upload, streamEvents, configure_2 } = client;' ); - expect(out).not.toContain('=> client.getOrder('); - // No flat sugar → the per-call option types are not imported (only re-exported). - expect(out).toContain( - "import { createClient, type OperationDescriptor, type TokenProvider } from '@redocly/client-generator';" - ); + expect(out).toContain('argsStyle: "flat"'); + // Merged: the path param sits beside the query params, with no layer keys. + expect(out).toContain('export type GetOrderVariables = {'); + expect(out).not.toContain('path: GetOrderPath;'); }); it('threads one schemaNames set: a suppressed alias is inlined in Ops, never referenced', () => { @@ -292,26 +287,6 @@ describe('emitClientSingleFile (package arm)', () => { expect(out).toContain('result: SearchResult;'); // the schema type, inlined }); - it('suffixes apiKey setters when several apiKey schemes exist; emits setBasicAuth for basic', () => { - const out = emit( - modelWith([getOrder], { - schemas: SCHEMAS, - securitySchemes: [ - { kind: 'basic', key: 'basicAuth' }, - { kind: 'apiKeyHeader', key: 'keyA', headerName: 'X-A' }, - { kind: 'apiKeyQuery', key: 'keyB', paramName: 'b' }, - ], - }) - ); - expect(out).toContain('export const setBasicAuth = client.auth.basic;'); - expect(out).toContain( - 'export const setApiKeyKeyA = (value: TokenProvider) => client.auth.apiKey("keyA", value);' - ); - expect(out).toContain( - 'export const setApiKeyKeyB = (value: TokenProvider) => client.auth.apiKey("keyB", value);' - ); - }); - it('handles a spec with no operations: uniform wiring over empty maps', () => { const out = emit(modelWith([]), {}); expect(out).toContain('export type Ops = Record;'); @@ -343,7 +318,8 @@ describe('emitClientSingleFile (package arm)', () => { }), ]) ); - expect(out).toContain('=> client.ping({ headers }, init) as Promise<'); + expect(out).toContain('export type PingHeaders = {\n "X-Trace"?: string;\n};'); + expect(out).toContain('headers?: PingHeaders;'); }); it('matches the golden output for a small model', () => { @@ -473,11 +449,11 @@ describe('emitClientSingleFile — pagination', () => { 'pagination: { style: "cursor", param: "cursor", nextCursor: "/nextCursor", items: "/orders" }' ); expect(out).toMatch( - /listOrders: \{\n\s+args: \{\n\s+params\?: ListOrdersParams;\n\s+\};\n\s+result: ListOrdersResult;\n\s+item: Order;\n\s+\};/ + /listOrders: \{\n\s+args: \{\n\s+query\?: ListOrdersQuery;\n\s+\};\n\s+result: ListOrdersResult;\n\s+item: Order;\n\s+\};/ ); }); - it('resolves the x-redocly-pagination extension without any config', () => { + it('resolves the x-redoclyPagination extension without any config', () => { const model = modelWith([{ ...listOrders, paginationExtension: CURSOR_RULE }, getOrder], { schemas: [...SCHEMAS, ORDER_PAGE], }); @@ -486,18 +462,13 @@ describe('emitClientSingleFile — pagination', () => { expect(out).toContain('pagination: { style: "cursor", param: "cursor",'); }); - it('wraps the flat sugar in Object.assign, preserving .pages/.items', () => { + it('the iterators ride the binding, so `.pages`/`.items` need no wrapper', () => { const out = emit(PAGINATED, { pagination: config }); - expect(out).toContain( - 'export const listOrders = Object.assign((params: {' - ); - expect(out).toContain('=> client.listOrders({ params }, init) as Promise<'); - expect(out).toContain('{ pages: client.listOrders.pages, items: client.listOrders.items });'); - // Non-paginated siblings keep the plain arrow. - expect(out).toContain( - 'export const getOrder = (orderId: string, params: {' - ); - expect(out).not.toContain('Object.assign((orderId'); + // `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;'); + expect(out).not.toContain('Object.assign('); + expect(out).toContain('item: Order;'); }); it('grouped argsStyle needs no wrapper — properties ride along on the destructure', () => { @@ -534,9 +505,9 @@ describe('emitClientSingleFile — pagination', () => { ); expect(() => emitClientSingleFile(model)).toThrow( 'Invalid pagination configuration:\n' + - ' - Pagination for operation "listOrders" (x-redocly-pagination): ' + - 'query parameter "after" is not declared on the operation\n' + - ' - Pagination for operation "listRefunds" (x-redocly-pagination): ' + + ' - Pagination for operation "listOrders" (x-redoclyPagination): ' + + 'query parameter "after" is not declared on the operation (declared: cursor, limit)\n' + + ' - Pagination for operation "listRefunds" (x-redoclyPagination): ' + 'the "items" pointer "/refunds" does not resolve in the success response schema' ); }); diff --git a/packages/client-generator/src/emitters/__tests__/descriptor.test.ts b/packages/client-generator/src/emitters/__tests__/descriptor.test.ts index a34407db9b..9b0c5bd1c0 100644 --- a/packages/client-generator/src/emitters/__tests__/descriptor.test.ts +++ b/packages/client-generator/src/emitters/__tests__/descriptor.test.ts @@ -3,14 +3,14 @@ import type { OperationModel, ResponseBodyModel, } from '../../intermediate-representation/model.js'; -import { descriptorStatements, opsInterfaceStatements, packageIdents } from '../descriptor.js'; +import { packageIdents, renderDescriptors } from '../descriptor.js'; import type { EmitContext } from '../operations.js'; import type { ModelPagination } from '../pagination.js'; -import { printStatements } from '../ts.js'; -import { apiModel, modelWith, namedSchema, operation, param, response } from './fixtures.js'; +import { renderOpsType } from '../render-client.js'; +import { apiModel, modelWith, operation, param } from './fixtures.js'; function emitDescriptors(model: ApiModel): string { - return printStatements(descriptorStatements(model, packageIdents(model), 'string')); + return renderDescriptors(model, packageIdents(model), 'string'); } /** A JSON 200 response — keeps `responseKind` at its omitted `'json'` default. */ @@ -23,11 +23,7 @@ const JSON_OK: ResponseBodyModel = { describe('packageIdents', () => { it('renames colliding operation ids deterministically', () => { const model = modelWith( - [ - operation({ name: 'configure' }), - operation({ name: 'createClient' }), - operation({ name: 'setBearer' }), - ], + [operation({ name: 'configure' }), operation({ name: 'createClient' })], { securitySchemes: [{ kind: 'bearer', key: 'bearerAuth' }], } @@ -35,7 +31,15 @@ describe('packageIdents', () => { const idents = packageIdents(model); expect(idents.get('configure')).toBe('configure_2'); expect(idents.get('createClient')).toBe('createClient_2'); - expect(idents.get('setBearer')).toBe('setBearer_2'); // auth sugar seeded first + }); + + it('leaves a name free once nothing exports it: no per-scheme setters, no reservation', () => { + // `setBearer` was a generated export, so an operation of that name had to be renamed. + // Credentials now go through `configure`/`client.auth`, so the name is the caller's. + const model = modelWith([operation({ name: 'setBearer' })], { + securitySchemes: [{ kind: 'bearer', key: 'bearerAuth' }], + }); + expect(packageIdents(model).get('setBearer')).toBe('setBearer'); }); it('keeps non-colliding names and sanitizes non-identifier ones', () => { @@ -55,9 +59,9 @@ describe('packageIdents', () => { }); }); -describe('descriptorStatements', () => { - it('returns no statements for a model with no operations', () => { - expect(descriptorStatements(apiModel(), new Map(), 'string')).toEqual([]); +describe('renderDescriptors', () => { + it('renders nothing for a model with no operations', () => { + expect(renderDescriptors(apiModel(), packageIdents(apiModel()), 'string')).toBe(''); }); it('emits a minimal descriptor with only the non-default fields', () => { @@ -335,9 +339,7 @@ describe('descriptorStatements', () => { }, ], ]); - const out = printStatements( - descriptorStatements(model, packageIdents(model), 'string', pagination) - ); + const out = renderDescriptors(model, packageIdents(model), 'string', pagination); expect(out).toContain( 'pagination: { style: "cursor", param: "cursor", limitParam: "limit", nextCursor: "/nextCursor", items: "/orders" }' ); @@ -351,11 +353,7 @@ describe('descriptorStatements', () => { operation({ name: 'listCustomers', path: '/customers', - successResponses: [ - response({ - schema: { kind: 'array', items: { kind: 'ref', name: 'Customer' } }, - }), - ], + successResponses: [JSON_OK], successResponseHeaders: [ { name: 'pagination-total', @@ -377,7 +375,7 @@ describe('descriptorStatements', () => { modelWith([ operation({ name: 'listCustomers', - successResponses: [response()], + successResponses: [JSON_OK], successResponseHeaders: [ { name: '3d-secure', schema: { kind: 'scalar', scalar: 'boolean' } }, { name: 'x-foo', schema: { kind: 'scalar', scalar: 'integer' } }, @@ -386,7 +384,6 @@ describe('descriptorStatements', () => { }), ]) ); - expect(out).toContain( 'responseHeaders: [{ name: "3d-secure", key: "_3dSecure", type: "boolean" }, { name: "x-foo", key: "xFoo", type: "number" }, { name: "x_foo", key: "xFoo_2", type: "string" }]' ); @@ -397,7 +394,7 @@ describe('descriptorStatements', () => { modelWith([ operation({ name: 'listCustomers', - successResponses: [response()], + successResponses: [JSON_OK], successResponseHeaders: [ { name: 'x-flag', @@ -417,59 +414,22 @@ describe('descriptorStatements', () => { }), ]) ); - expect(out).toContain( 'responseHeaders: [{ name: "x-flag", key: "xFlag", type: "boolean" }, { name: "x-count", key: "xCount", type: "number" }]' ); }); - - it('resolves $ref and allOf wrappers on response-header schemas to the coerce type', () => { - const out = emitDescriptors( - apiModel({ - schemas: [namedSchema('Count', { kind: 'scalar', scalar: 'integer' })], - services: [ - { - name: 'Default', - operations: [ - operation({ - name: 'listCustomers', - successResponses: [response()], - successResponseHeaders: [ - { name: 'x-total', schema: { kind: 'ref', name: 'Count' } }, - { - name: 'x-capped', - schema: { - kind: 'intersection', - members: [ - { kind: 'ref', name: 'Count' }, - { kind: 'unknown', metadata: { minimum: 0 } }, - ], - }, - }, - ], - }), - ], - }, - ], - }) - ); - - expect(out).toContain( - 'responseHeaders: [{ name: "x-total", key: "xTotal", type: "number" }, { name: "x-capped", key: "xCapped", type: "number" }]' - ); - }); }); -describe('opsInterfaceStatements', () => { +describe('renderOpsType', () => { function emitOps(model: ApiModel, extra: Partial = {}): string { const ctx: EmitContext = { - argsStyle: 'flat', + argsStyle: 'grouped', errorMode: 'throw', dateType: 'string', schemaNames: new Set(), ...extra, }; - return printStatements(opsInterfaceStatements(model, packageIdents(model), ctx)); + return renderOpsType(model, packageIdents(model), ctx); } const getOrder = operation({ @@ -516,14 +476,14 @@ describe('opsInterfaceStatements', () => { ); expect(out).toContain('export type Ops = {'); expect(out).toMatch( - /getOrder: \{\n {8}args: \{\n {12}orderId: string;\n {12}params\?: GetOrderParams;\n {8}\};\n {8}result: GetOrderResult;\n {4}\};/ + /getOrder: \{\n {8}args: \{\n {12}path: GetOrderPath;\n {12}query\?: GetOrderQuery;\n {8}\};\n {8}result: GetOrderResult;\n {4}\};/ ); expect(out).not.toContain('kind: "sse"'); }); it('keys args path params by wire name, quoted when not identifier-safe', () => { - // The runtime routes path values by wire name (`splitArgs` reads `args[param.name]`), - // so the args type must key them the same way — never by the sanitized ident. + // The runtime substitutes path values by wire name, so the args type must key them the + // same way — never by a sanitized ident. const out = emitOps( modelWith([ operation({ @@ -531,13 +491,16 @@ describe('opsInterfaceStatements', () => { path: '/pets/{pet-id}', pathParams: [param('pet-id', 'path', true)], }), - ]) + ]), + { schemaNames: new Set(['GetPetPath']) } ); expect(out).toContain('"pet-id": string;'); expect(out).not.toContain('pet_id'); }); it('keeps path params that sanitize to the same ident distinct via their wire names', () => { + // `schemaNames` holds the alias name, so the layer's type is inlined here and the keys + // are visible in `Ops` itself. const out = emitOps( modelWith([ operation({ @@ -545,7 +508,8 @@ describe('opsInterfaceStatements', () => { path: '/x/{a-b}/{a.b}', pathParams: [param('a-b', 'path', true), param('a.b', 'path', true)], }), - ]) + ]), + { schemaNames: new Set(['ComparePath']) } ); expect(out).toContain('"a-b": string;'); expect(out).toContain('"a.b": string;'); @@ -650,11 +614,11 @@ describe('opsInterfaceStatements', () => { ]); const out = emitOps(modelWith([listOrders, getOrder]), { pagination }); expect(out).toMatch( - /listOrders: \{\n {8}args: \{\n {12}params\?: ListOrdersParams;\n {8}\};\n {8}result: ListOrdersResult;\n {8}item: Order;\n {4}\};/ + /listOrders: \{\n {8}args: \{\n {12}query\?: ListOrdersQuery;\n {8}\};\n {8}result: ListOrdersResult;\n {8}item: Order;\n {4}\};/ ); // The non-paginated sibling stays untouched. expect(out).toMatch( - /getOrder: \{\n {8}args: \{\n {12}orderId: string;\n {8}\};\n {8}result: GetOrderResult;\n {4}\};/ + /getOrder: \{\n {8}args: \{\n {12}path: GetOrderPath;\n {8}\};\n {8}result: GetOrderResult;\n {4}\};/ ); }); @@ -683,7 +647,7 @@ describe('opsInterfaceStatements', () => { // Result mode: `result` is the envelope, so `page` carries the raw page for `.pages()`. const out = emitOps(modelWith([listOrders]), { pagination, errorMode: 'result' }); expect(out).toMatch( - /listOrders: \{\n {8}args: \{\n {12}params\?: ListOrdersParams;\n {8}\};\n {8}result: Result;\n {8}mode: "result";\n {8}item: Order;\n {8}page: ListOrdersResult;\n {4}\};/ + /listOrders: \{\n {8}args: \{\n {12}query\?: ListOrdersQuery;\n {8}\};\n {8}result: Result;\n {8}mode: "result";\n {8}item: Order;\n {8}page: ListOrdersResult;\n {4}\};/ ); // Throw mode emits no page member — `result` already IS the raw page. expect(emitOps(modelWith([listOrders]), { pagination })).not.toContain('page:'); @@ -721,57 +685,4 @@ describe('opsInterfaceStatements', () => { const out = emitOps(modelWith([listOrders]), { pagination, dateType: 'Date' }); expect(out).toContain('item: Date;'); }); - - it('adds a headers member from declared success-response headers', () => { - const out = emitOps( - modelWith([ - operation({ - name: 'listCustomers', - path: '/customers', - successResponses: [ - response({ - schema: { kind: 'array', items: { kind: 'ref', name: 'Customer' } }, - }), - ], - successResponseHeaders: [ - { name: 'pagination-total', schema: { kind: 'scalar', scalar: 'integer' } }, - ], - }), - ]) - ); - expect(out).toContain('headers: {\n paginationTotal?: number;\n };'); - }); - - it('emits safe unique keys, requiredness, and only runtime-supported header types', () => { - const out = emitOps( - modelWith([ - operation({ - name: 'listCustomers', - path: '/customers', - successResponses: [response()], - successResponseHeaders: [ - { - name: '3d-secure', - schema: { kind: 'scalar', scalar: 'boolean' }, - required: true, - }, - { name: 'x-foo', schema: { kind: 'scalar', scalar: 'integer' } }, - { name: 'x_foo', schema: { kind: 'scalar', scalar: 'string' } }, - { - name: 'x-ids', - schema: { - kind: 'array', - items: { kind: 'scalar', scalar: 'integer' }, - }, - }, - ], - }), - ]) - ); - - expect(out).toContain('_3dSecure: boolean;'); - expect(out).toContain('xFoo?: number;'); - expect(out).toContain('xFoo_2?: string;'); - expect(out).toContain('xIds?: string;'); - }); }); diff --git a/packages/client-generator/src/emitters/__tests__/faker.test.ts b/packages/client-generator/src/emitters/__tests__/faker.test.ts index 0c22d41072..ea5a167fe3 100644 --- a/packages/client-generator/src/emitters/__tests__/faker.test.ts +++ b/packages/client-generator/src/emitters/__tests__/faker.test.ts @@ -1,14 +1,14 @@ import type { NamedSchemaModel, SchemaModel } from '../../intermediate-representation/model.js'; import { fakerExpression } from '../faker.js'; -import { printNodes } from '../ts.js'; +import { renderMockValue } from '../mock-value.js'; -/** Emit `schema`'s faker expression and print it to source for substring assertions. */ +/** Emit `schema`'s faker expression and render it to source for substring assertions. */ function emit( schema: SchemaModel, schemas: NamedSchemaModel[] = [], dateType?: 'string' | 'Date' ): string { - return printNodes([fakerExpression(schema, schemas, { dateType })]); + return renderMockValue(fakerExpression(schema, schemas, { dateType }), ''); } describe('fakerExpression', () => { @@ -337,9 +337,10 @@ describe('fakerExpression', () => { }); it('defaults dateType to string when opts is omitted', () => { - const out = printNodes([ + const out = renderMockValue( fakerExpression({ kind: 'scalar', scalar: 'string', metadata: { format: 'date-time' } }, []), - ]); + '' + ); expect(out).toBe('faker.date.recent().toISOString()'); }); diff --git a/packages/client-generator/src/emitters/__tests__/identifier.test.ts b/packages/client-generator/src/emitters/__tests__/identifier.test.ts index 0aeee91550..fa5bc15b80 100644 --- a/packages/client-generator/src/emitters/__tests__/identifier.test.ts +++ b/packages/client-generator/src/emitters/__tests__/identifier.test.ts @@ -49,6 +49,13 @@ describe('uniqueIdent', () => { 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'); diff --git a/packages/client-generator/src/emitters/__tests__/inline-runtime.test.ts b/packages/client-generator/src/emitters/__tests__/inline-runtime.test.ts index 44453acc7e..8de8539275 100644 --- a/packages/client-generator/src/emitters/__tests__/inline-runtime.test.ts +++ b/packages/client-generator/src/emitters/__tests__/inline-runtime.test.ts @@ -1,5 +1,6 @@ +import ts from 'typescript'; + import { assembleInlineRuntime } from '../inline-runtime.js'; -import { ts } from '../ts.js'; const NONE = { multipart: false, auth: false, sse: false, setup: false, paginate: false }; const ALL = { multipart: true, auth: true, sse: true, setup: true, paginate: true }; diff --git a/packages/client-generator/src/emitters/__tests__/operation-signature.test.ts b/packages/client-generator/src/emitters/__tests__/operation-signature.test.ts index a38dcea899..de6528fc27 100644 --- a/packages/client-generator/src/emitters/__tests__/operation-signature.test.ts +++ b/packages/client-generator/src/emitters/__tests__/operation-signature.test.ts @@ -1,26 +1,24 @@ -import { operationSignature } from '../operation-signature.js'; +import { operationSignature, templatePathParams } from '../operation-signature.js'; import { operation, param } from './fixtures.js'; describe('operationSignature', () => { - it('orders path params by URL-template position and assigns unique identifiers', () => { - // Declared out of order; the path dictates order. `a-b` sanitizes to `a_b`. - const sig = operationSignature( + it('orders path params by URL-template position, keeping their wire names', () => { + // Declared out of order; the path dictates order. The wire name is the key in the + // `path` layer, so no binding identifier is derived from it any more. + const params = templatePathParams( operation({ path: '/x/{second}/y/{a-b}', pathParams: [param('a-b', 'path', true), param('second', 'path', true)], }) ); - expect(sig.pathParams.map((p) => p.param.name)).toEqual(['second', 'a-b']); - expect(sig.pathParams.map((p) => p.ident)).toEqual(['second', 'a_b']); + expect(params.map((param) => param.name)).toEqual(['second', 'a-b']); }); - it('renames a path param binding that would collide with the trailing init argument', () => { - // The wire name stays `init` (the flat sugar remaps `{ init: init_2 }`); only the - // local binding moves aside for the trailing `init: RequestOptions` parameter. - const sig = operationSignature( - operation({ path: '/x/{init}', pathParams: [param('init', 'path', true)] }) + it('drops a declared path param the template never mentions', () => { + const params = templatePathParams( + operation({ path: '/x', pathParams: [param('ghost', 'path', true)] }) ); - expect(sig.pathParams.map((p) => p.ident)).toEqual(['init_2']); + expect(params).toEqual([]); }); it('reports slot presence and hasInputs', () => { diff --git a/packages/client-generator/src/emitters/__tests__/operations.test.ts b/packages/client-generator/src/emitters/__tests__/operations.test.ts index 14e94c7323..6dd560bad8 100644 --- a/packages/client-generator/src/emitters/__tests__/operations.test.ts +++ b/packages/client-generator/src/emitters/__tests__/operations.test.ts @@ -1,7 +1,6 @@ -// The flat sugar signatures (`renderArgList`) and the `*` operation aliases as -// they appear in the descriptor-wired single-file client. The wiring itself (Ops, -// OPERATIONS, client, auth sugar) is covered in client-assembly.test.ts; here the -// focus is one operation's developer-facing surface. +// 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 { emitClientSingleFile } from '../client-assembly.js'; import { SCALAR, apiModel, emitWithOp, namedSchema, operation, param } from './fixtures.js'; @@ -17,26 +16,14 @@ function emitResult(op: Partial, schemas: string[] = []): string ); } -/** Throw-mode flat sugar is generic over `init` so `{ envelope: true }` narrows the return. */ -function envelopeFlatSugar( - name: string, - argsBeforeInit: string, - callArgs: string, - resultType: string, - headersType = 'Record' -): string { - const params = argsBeforeInit ? `${argsBeforeInit}, init?: I` : 'init?: I'; - const promise = `Promise>`; - return `export const ${name} = (${params}): ${promise} => client.${name}(${callArgs}, init) as ${promise};`; -} - -describe('flat sugar — argument-list permutations (renderArgList)', () => { - it('renders an operation with no inputs: only the trailing init, forwarding empty args', () => { +describe('call inputs — the namespaced shape', () => { + it('an operation with no inputs has no Variables type and is exported as a binding', () => { const out = emitWithOp({}); - expect(out).toContain(envelopeFlatSugar('op', '', '{}', 'OpResult')); + expect(out).not.toContain('OpVariables'); + expect(out).toContain('export const { op } = client;'); }); - it('orders path params by their position in the URL template, not in pathParams[]', () => { + it('groups path params under `path`, in URL-template order', () => { const out = emitWithOp({ name: 'getNested', path: '/x/{first}/y/{second}', @@ -46,61 +33,37 @@ describe('flat sugar — argument-list permutations (renderArgList)', () => { ], }); expect(out).toContain( - envelopeFlatSugar( - 'getNested', - 'first: string, second: number', - '{ first, second }', - 'GetNestedResult' - ) + 'export type GetNestedPath = {\n first: string;\n second: number;\n};' ); + expect(out).toContain('path: GetNestedPath;'); }); - it('skips path params that are declared but missing from the URL template', () => { + it('drops a path param that the URL template never mentions', () => { + // The descriptor still lists the declared parameter; the input type must not ask for + // a value that has nowhere to go in the URL. const out = emitWithOp({ path: '/x', pathParams: [param('ghost', 'path', true)] }); + expect(out).not.toContain('OpPath'); expect(out).not.toContain('ghost: string'); }); - it('sanitizes a non-identifier path param name into a safe argument, keyed by wire name', () => { + it('keys a non-identifier param by its wire name, quoted', () => { const out = emitWithOp({ name: 'getPet', path: '/pets/{pet-id}', pathParams: [param('pet-id', 'path', true)], }); - expect(out).toContain( - envelopeFlatSugar('getPet', 'pet_id: string', '{ "pet-id": pet_id }', 'GetPetResult') - ); - }); - - it('prefixes digit-leading and reserved-word path param names with `_`', () => { - expect(emitWithOp({ path: '/x/{2fa}', pathParams: [param('2fa', 'path', true)] })).toContain( - '_2fa: string' - ); - expect(emitWithOp({ path: '/x/{new}', pathParams: [param('new', 'path', true)] })).toContain( - '_new: string' - ); - }); - - it('disambiguates path param names that sanitize to the same identifier', () => { - const out = emitWithOp({ - path: '/x/{a-b}/{a.b}', - pathParams: [param('a-b', 'path', true), param('a.b', 'path', true)], - }); - expect(out).toContain('a_b: string'); - expect(out).toContain('a_b_2: string'); + expect(out).toContain('export type GetPetPath = {\n "pet-id": string;\n};'); }); - it('emits `params = {}` default when all query params are optional', () => { - const out = emitWithOp({ + it('`query` is optional when every query param is, required when one is not', () => { + const optional = emitWithOp({ queryParams: [param('q', 'query', false), param('r', 'query', false)], }); - expect(out).toMatch(/params: \{\n {4}q\?: string;\n {4}r\?: string;\n\} = \{\}/); - }); - - it('makes `params` required when at least one query param is required', () => { - const out = emitWithOp({ + expect(optional).toContain('query?: OpQuery;'); + const required = emitWithOp({ queryParams: [param('q', 'query', true), param('r', 'query', false)], }); - expect(out).toMatch(/params: \{\n {4}q: string;\n {4}r\?: string;\n\}, init/); + expect(required).toContain('query: OpQuery;'); }); it('produces `body: T` for required JSON bodies and `body?: T` for optional ones', () => { @@ -109,60 +72,46 @@ describe('flat sugar — argument-list permutations (renderArgList)', () => { schema: { kind: 'ref', name: 'Pet' }, required: true, }; - expect(emitWithOp({ requestBody: required })).toContain('body: Pet'); + const out = emitWithOp({ requestBody: required }); + expect(out).toContain('export type OpBody = Pet;'); + expect(out).toContain('body: OpBody;'); const optional: RequestBodyModel = { contentType: 'application/json', schema: SCALAR, required: false, }; - expect(emitWithOp({ requestBody: optional })).toContain('body?: string'); - }); - - it('uses raw `FormData` for a non-object multipart body', () => { - const body: RequestBodyModel = { - contentType: 'multipart/form-data', - schema: { kind: 'unknown' }, - required: true, - }; - expect(emitWithOp({ requestBody: body })).toContain('body: FormData'); - }); - - it('uses `URLSearchParams` for urlencoded bodies', () => { - const body: RequestBodyModel = { - contentType: 'application/x-www-form-urlencoded', - schema: { kind: 'object', properties: [] }, - required: true, - }; - expect(emitWithOp({ requestBody: body })).toContain('body: URLSearchParams'); + expect(emitWithOp({ requestBody: optional })).toContain('body?: OpBody;'); }); - it('uses `Blob | ArrayBuffer` for octet-stream bodies', () => { - const body: RequestBodyModel = { - contentType: 'application/octet-stream', - schema: SCALAR, - required: true, - }; - expect(emitWithOp({ requestBody: body })).toContain('body: Blob | ArrayBuffer'); + it('types a non-JSON body by its content type', () => { + const bodyOf = (contentType: string, schema: RequestBodyModel['schema']): string => + emitWithOp({ requestBody: { contentType, schema, required: true } }); + expect(bodyOf('multipart/form-data', { kind: 'unknown' })).toContain( + 'export type OpBody = FormData;' + ); + expect( + bodyOf('application/x-www-form-urlencoded', { kind: 'object', properties: [] }) + ).toContain('export type OpBody = URLSearchParams;'); + expect(bodyOf('application/octet-stream', SCALAR)).toContain( + 'export type OpBody = Blob | ArrayBuffer;' + ); }); - it('emits header params as a typed `headers` slot, forwarded to the client method', () => { - const out = emitWithOp({ + it('groups header params under `headers`, optional when all of them are', () => { + const required = emitWithOp({ name: 'getThing', headerParams: [param('X-Api-Version', 'header', true)], }); - expect(out).toMatch(/headers: \{\n {4}"X-Api-Version": string;\n\}, init/); - expect(out).toContain('=> client.getThing({ headers }, init) as Promise<'); - }); - - it('defaults the `headers` slot to `= {}` when all header params are optional', () => { - const out = emitWithOp({ + expect(required).toContain('export type GetThingHeaders = {\n "X-Api-Version": string;\n};'); + expect(required).toContain('headers: GetThingHeaders;'); + const optional = emitWithOp({ name: 'getThing', headerParams: [param('X-Trace', 'header', false)], }); - expect(out).toMatch(/headers: \{\n {4}"X-Trace"\?: string;\n\} = \{\}/); + expect(optional).toContain('headers?: GetThingHeaders;'); }); - it('renders per-param JSDoc (description + schema metadata) above sugar params', () => { + it('renders per-param JSDoc (description + schema metadata)', () => { const out = emitWithOp({ name: 'listPets', queryParams: [ @@ -178,7 +127,7 @@ describe('flat sugar — argument-list permutations (renderArgList)', () => { expect(out).toMatch(/Page size\.[\s\S]*@minimum 1[\s\S]*@maximum 100[\s\S]*limit\?: number;/); }); - it('the SSE sugar takes `SseOptions`; regular ops take `RequestOptions`', () => { + it('exports one binding per operation — SSE included, with no wrapper in sight', () => { const out = emitClientSingleFile( apiModel({ services: [ @@ -198,10 +147,130 @@ describe('flat sugar — argument-list permutations (renderArgList)', () => { ], }) ); + expect(out).toContain('export const { streamMessages, listThings } = client;'); + expect(out).not.toContain('=> client.streamMessages('); + }); +}); + +describe('call inputs — the merged shape (argsStyle: flat)', () => { + /** Emit a flat-style client whose only operation is `operation(op)`. */ + function emitFlat(op: Partial): string { + return emitClientSingleFile( + apiModel({ services: [{ name: 'Default', operations: [operation(op)] }] }), + { argsStyle: 'flat' } + ); + } + + it('puts every parameter at one level and intersects a required object body', () => { + const out = emitFlat({ + name: 'updateThing', + path: '/things/{id}', + pathParams: [param('id', 'path', true)], + queryParams: [param('dryRun', 'query', false, { kind: 'scalar', scalar: 'boolean' })], + requestBody: { + contentType: 'application/json', + schema: { + kind: 'object', + properties: [{ name: 'status', schema: SCALAR, required: true }], + }, + required: true, + }, + }); expect(out).toContain( - 'export const streamMessages = (init: SseOptions = {}) => client.streamMessages({}, init);' + 'export type UpdateThingVariables = {\n id: string;\n dryRun?: boolean;\n} & UpdateThingBody;' ); - expect(out).toContain(envelopeFlatSugar('listThings', '', '{}', 'ListThingsResult')); + // The client is told which shape its types promise, so the runtime converts before use. + expect(out).toContain('argsStyle: "flat"'); + }); + + it('keeps the `body` key for a body a merged call cannot spread', () => { + const out = emitFlat({ + name: 'upload', + requestBody: { contentType: 'application/octet-stream', schema: SCALAR, required: true }, + }); + expect(out).toContain('export type UploadVariables = {\n body: UploadBody;\n};'); + }); + + it('an optional body stays a `body` key: omitting it must differ from omitting its fields', () => { + const out = emitFlat({ + name: 'patchThing', + requestBody: { + contentType: 'application/json', + schema: { + kind: 'object', + properties: [{ name: 'status', schema: SCALAR, required: true }], + }, + required: false, + }, + }); + expect(out).toContain('body?: PatchThingBody;'); + }); + + it('counts the properties of an allOf body, which a merged call would spread too', () => { + const out = emitFlat({ + name: 'saveThing', + path: '/things/{id}', + pathParams: [param('id', 'path', true)], + queryParams: [param('label', 'query', false)], + requestBody: { + contentType: 'application/json', + required: true, + schema: { + kind: 'intersection', + members: [ + { kind: 'object', properties: [{ name: 'label', schema: SCALAR, required: true }] }, + { kind: 'object', properties: [{ name: 'note', schema: SCALAR, required: false }] }, + ], + }, + }, + }); + // `label` arrives from the query AND from the body, so the merged shape is impossible. + expect(out).toContain('path: SaveThingPath;'); + expect(out).toContain('query?: SaveThingQuery;'); + expect(out).toContain('body: SaveThingBody;'); + // The descriptor says the same, so the runtime takes the namespaced call. + expect(out).toContain('argsStyle: "grouped"'); + }); + + it('a property two allOf members declare is one key, not a collision', () => { + const out = emitFlat({ + name: 'saveThing', + path: '/things/{id}', + pathParams: [param('id', 'path', true)], + requestBody: { + contentType: 'application/json', + required: true, + schema: { + kind: 'intersection', + members: [ + { + kind: 'object', + properties: [ + { name: 'label', schema: SCALAR, required: true }, + { name: 'note', schema: SCALAR, required: false }, + ], + }, + // A refinement of the same property — the merged body still has one `label`. + { kind: 'object', properties: [{ name: 'label', schema: SCALAR, required: false }] }, + ], + }, + }, + }); + expect(out).toContain( + 'export type SaveThingVariables = {\n id: string;\n} & SaveThingBody;' + ); + expect(out).not.toContain('argsStyle: "grouped"'); + }); + + it('falls back to the namespaced shape when one name lands in two layers', () => { + const out = emitFlat({ + name: 'getThing', + path: '/things/{id}', + pathParams: [param('id', 'path', true)], + queryParams: [param('id', 'query', false)], + }); + expect(out).toContain('path: GetThingPath;'); + expect(out).toContain('query?: GetThingQuery;'); }); }); @@ -220,7 +289,7 @@ describe('operation type aliases (*Result / *Params / *Body / *Headers / *Variab expect(out).toContain('export type GetPetResult = Pet;'); }); - it('emits *Params/*Body/*Headers/*Variables per input kind, in a stable order', () => { + it('emits *Path/*Query/*Body/*Headers/*Variables per input kind, in a stable order', () => { const out = emitWithOp({ name: 'updateOrder', path: '/orders/{orderId}', @@ -238,7 +307,8 @@ describe('operation type aliases (*Result / *Params / *Body / *Headers / *Variab }); const names = [ 'UpdateOrderResult', - 'UpdateOrderParams', + 'UpdateOrderPath', + 'UpdateOrderQuery', 'UpdateOrderBody', 'UpdateOrderHeaders', 'UpdateOrderVariables', @@ -250,7 +320,7 @@ describe('operation type aliases (*Result / *Params / *Body / *Headers / *Variab last = idx; } expect(out).toMatch( - /export type UpdateOrderVariables = \{[\s\S]*orderId: string;[\s\S]*params\?: UpdateOrderParams;[\s\S]*body: UpdateOrderBody;[\s\S]*headers\?: UpdateOrderHeaders;[\s\S]*\};/ + /export type UpdateOrderVariables = \{[\s\S]*path: UpdateOrderPath;[\s\S]*query\?: UpdateOrderQuery;[\s\S]*body: UpdateOrderBody;[\s\S]*headers\?: UpdateOrderHeaders;[\s\S]*\};/ ); }); diff --git a/packages/client-generator/src/emitters/__tests__/pagination.test.ts b/packages/client-generator/src/emitters/__tests__/pagination.test.ts index fbb6aa7100..8475d40f89 100644 --- a/packages/client-generator/src/emitters/__tests__/pagination.test.ts +++ b/packages/client-generator/src/emitters/__tests__/pagination.test.ts @@ -314,7 +314,7 @@ describe('resolveOperationPagination — sources and precedence', () => { }); }); - it('applies the x-redocly-pagination extension when no per-op rule exists', () => { + it('applies the x-redoclyPagination extension when no per-op rule exists', () => { const op = listOrders({ paginationExtension: OFFSET_RULE }); const result = resolveOperationPagination(op, modelWith([op]), undefined); expect(result.spec).toEqual({ style: 'offset', param: 'offset', items: '/orders' }); @@ -431,14 +431,12 @@ describe('resolveOperationPagination — rule-shape validation (any source)', () '"limitParam" must be a query parameter name', ], ])( - 'rejects %s from the extension with the x-redocly-pagination source', + 'rejects %s from the extension with the x-redoclyPagination source', (_case, rule, problem) => { const op = listOrders({ paginationExtension: rule }); const { spec, error } = resolveOperationPagination(op, model(), undefined); expect(spec).toBeUndefined(); - expect(error).toBe( - `Pagination for operation "listOrders" (x-redocly-pagination): ${problem}` - ); + expect(error).toBe(`Pagination for operation "listOrders" (x-redoclyPagination): ${problem}`); } ); @@ -469,7 +467,7 @@ describe('resolveOperationPagination — fit verification', () => { [ 'an advance param missing from the query params', { ...CURSOR_RULE, cursorParam: 'after' }, - 'query parameter "after" is not declared on the operation', + 'query parameter "after" is not declared on the operation (declared: cursor, offset, page, limit)', ], [ 'an unresolvable items pointer', @@ -490,7 +488,7 @@ describe('resolveOperationPagination — fit verification', () => { const op = listOrders({ paginationExtension: rule }); const { spec, error } = resolveOperationPagination(op, modelWith([op]), undefined); expect(spec).toBeUndefined(); - expect(error).toBe(`Pagination for operation "listOrders" (x-redocly-pagination): ${problem}`); + expect(error).toBe(`Pagination for operation "listOrders" (x-redoclyPagination): ${problem}`); }); it('convention that does not fit resolves to nothing, silently', () => { @@ -512,7 +510,7 @@ describe('resolveOperationPagination — fit verification', () => { }); const { error } = resolveOperationPagination(op, modelWith([op]), undefined); expect(error).toBe( - 'Pagination for operation "listOrders" (x-redocly-pagination): ' + + 'Pagination for operation "listOrders" (x-redoclyPagination): ' + 'the operation has no JSON success response' ); const conventionOnly = listOrders({ @@ -530,7 +528,7 @@ describe('resolveOperationPagination — fit verification', () => { }); const { error } = resolveOperationPagination(sseOp, modelWith([sseOp]), undefined); expect(error).toBe( - 'Pagination for operation "listOrders" (x-redocly-pagination): ' + + 'Pagination for operation "listOrders" (x-redoclyPagination): ' + 'the operation is a Server-Sent Events stream' ); const conventionOnly = listOrders({ @@ -613,9 +611,7 @@ describe('resolveOperationPagination — fit verification', () => { const op = withParamSchema(name, schema, rule); const { spec, error } = resolveOperationPagination(op, modelWith([op]), undefined); expect(spec).toBeUndefined(); - expect(error).toBe( - `Pagination for operation "listOrders" (x-redocly-pagination): ${problem}` - ); + expect(error).toBe(`Pagination for operation "listOrders" (x-redoclyPagination): ${problem}`); }); it('convention with a misfitting advance param resolves to nothing, silently', () => { @@ -743,9 +739,9 @@ describe('resolveModelPagination', () => { }); expect(() => resolveModelPagination(modelWith([bad1, bad2]), undefined)).toThrow( 'Invalid pagination configuration:\n' + - ' - Pagination for operation "listOrders" (x-redocly-pagination): ' + - 'query parameter "after" is not declared on the operation\n' + - ' - Pagination for operation "listRefunds" (x-redocly-pagination): ' + + ' - Pagination for operation "listOrders" (x-redoclyPagination): ' + + 'query parameter "after" is not declared on the operation (declared: cursor, offset, page, limit)\n' + + ' - Pagination for operation "listRefunds" (x-redoclyPagination): ' + '"style" must be one of "cursor" | "offset" | "page" | "link" (got "nope")' ); }); diff --git a/packages/client-generator/src/emitters/__tests__/reserved-names.test.ts b/packages/client-generator/src/emitters/__tests__/reserved-names.test.ts index 5f6ec6ffb3..6311c7987a 100644 --- a/packages/client-generator/src/emitters/__tests__/reserved-names.test.ts +++ b/packages/client-generator/src/emitters/__tests__/reserved-names.test.ts @@ -1,6 +1,7 @@ +import ts from 'typescript'; + import { reservedModuleNames } from '../reserved-names.js'; import { RUNTIME_SOURCES } from '../runtime-sources.js'; -import { ts } from '../ts.js'; /** * Every free identifier of a source — referenced but bound in no enclosing scope, so diff --git a/packages/client-generator/src/emitters/__tests__/sse.test.ts b/packages/client-generator/src/emitters/__tests__/sse.test.ts index fb66c99588..dacea5a0cf 100644 --- a/packages/client-generator/src/emitters/__tests__/sse.test.ts +++ b/packages/client-generator/src/emitters/__tests__/sse.test.ts @@ -1,6 +1,5 @@ import type { ResponseBodyModel, SchemaModel } from '../../intermediate-representation/model.js'; -import { isSseOp, sseDataKind, sseEventType } from '../sse.js'; -import { printNodes } from '../ts.js'; +import { eventSchema, isSseOp, sseDataKind } from '../sse.js'; import { operation } from './fixtures.js'; /** An operation whose success response streams `text/event-stream`. */ @@ -40,37 +39,32 @@ describe('isSseOp', () => { }); }); -describe('sseEventType', () => { - it('uses the per-item schema when present (a ref → a Message reference)', () => { - const out = printNodes([ - sseEventType(sseOp({ itemSchema: { kind: 'ref', name: 'Message' } }), 'string'), - ]); - expect(out).toContain('Message'); +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', () => { - const out = printNodes([ - sseEventType(sseOp({ schema: { kind: 'ref', name: 'Token' } }), 'string'), - ]); - expect(out).toContain('Token'); + 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', () => { - const out = printNodes([ - sseEventType( - sseOp({ itemSchema: { kind: 'unknown' }, schema: { kind: 'ref', name: 'Token' } }), - 'string' - ), - ]); - expect(out).toContain('Token'); - }); - - it('falls back to the `string` keyword when no schema is declared', () => { - expect(printNodes([sseEventType(sseOp({}), 'string')])).toBe('string'); + expect( + eventSchema( + sseOp({ itemSchema: { kind: 'unknown' }, schema: { kind: 'ref', name: 'Token' } }) + ) + ).toEqual({ kind: 'ref', name: 'Token' }); }); - it('falls back to `string` when the op is not an SSE op at all', () => { - expect(printNodes([sseEventType(operation({}), 'string')])).toBe('string'); + it('is undefined when no schema is declared (payload types as `string`)', () => { + expect(eventSchema(sseOp({}))).toBeUndefined(); + expect(eventSchema(operation({}))).toBeUndefined(); }); }); diff --git a/packages/client-generator/src/emitters/__tests__/swr.test.ts b/packages/client-generator/src/emitters/__tests__/swr.test.ts index addf0b6031..7c9fac8770 100644 --- a/packages/client-generator/src/emitters/__tests__/swr.test.ts +++ b/packages/client-generator/src/emitters/__tests__/swr.test.ts @@ -3,16 +3,16 @@ import { apiModel, namedSchema, operation, param, SCALAR } from './fixtures.js'; const SDK = './client.js'; -function render(ops: Parameters[0][], argsStyle: 'flat' | 'grouped' = 'grouped') { +function render(ops: Parameters[0][]) { return renderSwrModule( apiModel({ services: [{ name: 'Default', operations: ops.map(operation) }] }), - { sdkModule: SDK, argsStyle } + { sdkModule: SDK } ); } describe('renderSwrModule', () => { it('returns empty string when the model has no operations', () => { - expect(renderSwrModule(apiModel(), { sdkModule: SDK, argsStyle: 'flat' })).toBe(''); + expect(renderSwrModule(apiModel(), { sdkModule: SDK })).toBe(''); }); it('skips SSE operations (not exported by the sdk) and wraps only the regular ones', () => { @@ -53,7 +53,7 @@ describe('renderSwrModule', () => { }, ], }), - { sdkModule: SDK, argsStyle: 'grouped' } + { sdkModule: SDK } ); expect(out).not.toContain('useGetUser'); expect(out).toContain('useListUsers'); @@ -106,13 +106,6 @@ describe('renderSwrModule', () => { 'return useSWR(listPetsKey(), () => listPets({}, { ...init, envelope: undefined }));' ); }); - - it('flat style: the no-input sugar takes the init directly', () => { - const out = render([{ name: 'listPets', method: 'get', path: '/pets' }], 'flat'); - expect(out).toContain( - 'return useSWR(listPetsKey(), () => listPets({ ...init, envelope: undefined }));' - ); - }); }); describe('mutation operation (POST) with a body', () => { @@ -140,41 +133,33 @@ describe('renderSwrModule', () => { }); }); - describe('flat forwarding', () => { - it('query: spreads vars., vars.params, then init (URL-template order)', () => { - const out = render( - [ - { - name: 'getPet', - method: 'get', - path: '/pets/{petId}', - pathParams: [param('petId', 'path', true)], - queryParams: [param('expand', 'query', false)], - }, - ], - 'flat' - ); - expect(out).toContain( - '() => getPet(vars.petId, vars.params, { ...init, envelope: undefined })' - ); + describe('input forwarding', () => { + it('forwards the whole input object, whatever shape the sdk takes', () => { + const out = render([ + { + name: 'getPet', + method: 'get', + path: '/pets/{petId}', + pathParams: [param('petId', 'path', true)], + queryParams: [param('expand', 'query', false)], + }, + ]); + expect(out).toContain('() => getPet(vars, { ...init, envelope: undefined })'); }); - it('mutation: spreads arg. (URL-template order), then params, body, headers', () => { - const out = render( - [ - { - name: 'replace', - method: 'put', - path: '/a/{a}/b/{b}', - pathParams: [param('b', 'path', true), param('a', 'path', true)], - queryParams: [param('q', 'query', false)], - requestBody: { contentType: 'application/json', schema: SCALAR, required: true }, - headerParams: [param('X-Trace', 'header', false)], - }, - ], - 'flat' - ); - expect(out).toContain('=> replace(arg.a, arg.b, arg.params, arg.body, arg.headers)'); + it('a mutation trigger forwards its `arg` the same way', () => { + const out = render([ + { + name: 'replace', + method: 'put', + path: '/a/{a}/b/{b}', + pathParams: [param('b', 'path', true), param('a', 'path', true)], + queryParams: [param('q', 'query', false)], + requestBody: { contentType: 'application/json', schema: SCALAR, required: true }, + headerParams: [param('X-Trace', 'header', false)], + }, + ]); + expect(out).toContain('}) => replace(arg));'); }); }); diff --git a/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts b/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts index ae56f1cc15..2233a36ef8 100644 --- a/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts +++ b/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts @@ -229,9 +229,9 @@ describe('renderTanstackModule', () => { ); expect(out).toContain('queryKey: [...listOrdersQueryKey(vars), "infinite"] as const'); expect(out).toContain( - 'queryFn: ({ pageParam, signal }) => instance.listOrders({ ...vars, params: { ...vars.params, after: pageParam } }, { ...init, signal, envelope: undefined })' + 'queryFn: ({ pageParam, signal }) => instance.listOrders({ ...vars, query: { ...vars.query, after: pageParam } }, { ...init, signal, envelope: undefined })' ); - expect(out).toContain('initialPageParam: vars.params?.after'); + expect(out).toContain('initialPageParam: vars.query?.after'); expect(out).toContain('if (lastPage.page?.hasNextPage === false)'); expect(out).toContain('const next = lastPage.page?.endCursor;'); // The cursor is a nullable string reached through an optional chain: all three stops. @@ -291,7 +291,7 @@ describe('renderTanstackModule', () => { const out = render([offsetOp], { pagination: { style: 'offset', offsetParam: 'offset', items: '/items' }, }); - expect(out).toContain('initialPageParam: vars.params?.offset ?? 0'); + expect(out).toContain('initialPageParam: vars.query?.offset ?? 0'); expect(out).toContain('getNextPageParam: (lastPage, _allPages, lastPageParam) => {'); expect(out).toContain('const count = lastPage.items?.length ?? 0;'); expect(out).toContain('return count === 0 ? undefined : lastPageParam + count;'); @@ -301,7 +301,7 @@ describe('renderTanstackModule', () => { const out = render([offsetOp], { pagination: { style: 'page', offsetParam: 'offset', items: '/items' }, }); - expect(out).toContain('initialPageParam: vars.params?.offset ?? 1'); + expect(out).toContain('initialPageParam: vars.query?.offset ?? 1'); expect(out).toContain('return count === 0 ? undefined : lastPageParam + 1;'); }); @@ -409,3 +409,51 @@ describe('renderTanstackModule', () => { }); }); }); + +describe('a pagination parameter whose name is not an identifier', () => { + const spec = { + name: 'listOrders', + method: 'get' as const, + path: '/orders', + queryParams: [param('after-cursor', 'query', false)], + successResponses: [ + { + contentType: 'application/json', + status: 200, + schema: { + kind: 'object' as const, + properties: [ + { name: 'items', schema: { kind: 'array' as const, items: SCALAR }, required: true }, + { name: 'next', schema: SCALAR, required: false }, + ], + }, + }, + ], + }; + const pagination: PaginationConfig = { + operations: { + listOrders: { + style: 'cursor', + cursorParam: 'after-cursor', + nextCursor: '/next', + items: '/items', + }, + }, + }; + + 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 } + ); + 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' } + ); + // `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/emitters/__tests__/ts-guard.test.ts b/packages/client-generator/src/emitters/__tests__/ts-guard.test.ts index 1a439582f4..42b03e5716 100644 --- a/packages/client-generator/src/emitters/__tests__/ts-guard.test.ts +++ b/packages/client-generator/src/emitters/__tests__/ts-guard.test.ts @@ -3,11 +3,11 @@ import { vi } from 'vitest'; describe('typescript compiler API guard', () => { it('fails with instructions when the installed typescript lacks the compiler API (TS 7+)', async () => { // typescript@7 (the native compiler) ships only the tsc binary: `import ts` resolves, - // but every compiler-API member is undefined. Without the guard the module dies on - // its first `ts.*` call with a bare TypeError. + // but every compiler-API member is undefined. Without the guard setup baking — the only + // place we parse TypeScript — dies on its first `ts.*` call with a bare TypeError. vi.resetModules(); vi.doMock('typescript', () => ({ default: { version: '7.0.2' } })); - await expect(import('../ts.js')).rejects.toThrow( + await expect(import('../setup-bake.js')).rejects.toThrow( /TypeScript 7.*ships only.*tsc.*typescript@6/s ); vi.doUnmock('typescript'); diff --git a/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts b/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts new file mode 100644 index 0000000000..540ff8e705 --- /dev/null +++ b/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts @@ -0,0 +1,61 @@ +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/__tests__/ts-type.test.ts b/packages/client-generator/src/emitters/__tests__/ts-type.test.ts new file mode 100644 index 0000000000..12b48fecae --- /dev/null +++ b/packages/client-generator/src/emitters/__tests__/ts-type.test.ts @@ -0,0 +1,110 @@ +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 +// generated client's types follow (4-space indent, double quotes, parenthesized +// compound members). The full surface is additionally pinned by the assembly goldens. + +const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' }; +const INT: SchemaModel = { kind: 'scalar', scalar: 'integer' }; + +describe('tsType', () => { + it.each<[string, SchemaModel, string]>([ + ['scalars', INT, 'number'], + ['binary → Blob', { kind: 'scalar', scalar: 'string', metadata: { format: 'binary' } }, 'Blob'], + ['ref', { kind: 'ref', name: 'Order' }, 'Order'], + ['literal', { kind: 'literal', value: 'fixed' }, '"fixed"'], + ['enum', { kind: 'enum', values: ['a', 'b'], scalar: 'string' }, '"a" | "b"'], + ['array of ref', { kind: 'array', items: { kind: 'ref', name: 'Order' } }, 'Order[]'], + [ + 'array of union (parenthesized)', + { kind: 'array', items: { kind: 'union', members: [STRING, { kind: 'null' }] } }, + '(string | null)[]', + ], + [ + 'nullable enum (the OAS 3.1 shape, parenthesized)', + { + kind: 'union', + members: [ + { kind: 'enum', values: ['active', 'archived'], scalar: 'string' }, + { kind: 'null' }, + ], + }, + '("active" | "archived") | null', + ], + ['record', { kind: 'record', value: STRING }, 'Record'], + ['omit', { kind: 'omit', base: 'Pet', keys: ['id'] }, 'Omit'], + [ + 'intersection with parenthesized union member', + { + kind: 'intersection', + members: [ + { kind: 'ref', name: 'Base' }, + { kind: 'union', members: [STRING, INT] }, + ], + }, + 'Base & (string | number)', + ], + ['empty object', { kind: 'object', properties: [] }, '{}'], + ])('%s', (_label, schema, expected) => { + expect(tsType(schema)).toBe(expected); + }); + + it('renders objects multiline with JSDoc, readonly, optional, and quoted keys', () => { + const schema: SchemaModel = { + kind: 'object', + properties: [ + { name: 'id', schema: STRING, required: true, readOnly: true }, + { name: 'note', schema: STRING, required: false, description: 'Free-form note.' }, + { name: 'weird-name', schema: INT, required: true }, + ], + }; + expect(tsType(schema)).toBe( + [ + '{', + ' readonly id: string;', + ' /**', + ' * Free-form note.', + ' */', + ' note?: string;', + ' "weird-name": number;', + '}', + ].join('\n') + ); + }); + + it('under dateType Date, date-formatted strings become Date', () => { + const schema: SchemaModel = { + kind: 'scalar', + scalar: 'string', + metadata: { format: 'date-time' }, + }; + expect(tsType(schema, 'Date')).toBe('Date'); + expect(tsType(schema, 'string')).toBe('string'); + }); +}); + +describe('renderTypeAliases', () => { + it('emits aliases with JSDoc and identifier-safe enum const companions', () => { + const schemas: NamedSchemaModel[] = [ + { name: 'Status', schema: { kind: 'enum', values: ['open', 'closed'], scalar: 'string' } }, + { + // `menu:read` is not a valid identifier — no const companion. + name: 'Scopes', + schema: { kind: 'enum', values: ['menu:read'], scalar: 'string' }, + }, + ] as NamedSchemaModel[]; + expect(renderTypeAliases(schemas)).toBe( + [ + 'export type Status = "open" | "closed";', + '', + 'export const Status = {', + ' open: "open",', + ' closed: "closed"', + '} as const;', + '', + 'export type Scopes = "menu:read";', + ].join('\n') + ); + }); +}); diff --git a/packages/client-generator/src/emitters/__tests__/ts.test.ts b/packages/client-generator/src/emitters/__tests__/ts.test.ts deleted file mode 100644 index 1d7ae1c86d..0000000000 --- a/packages/client-generator/src/emitters/__tests__/ts.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { - jsdoc, - literalExpression, - parseExpression, - parseStatements, - printNodes, - printStatements, - ts, -} from '../ts.js'; - -describe('emitters/ts foundation', () => { - describe('printNodes', () => { - it('round-trips an interface declaration', () => { - const decl = ts.factory.createInterfaceDeclaration( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - 'Foo', - undefined, - undefined, - [ - ts.factory.createPropertySignature( - undefined, - 'id', - undefined, - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) - ), - ] - ); - expect(printNodes([decl])).toBe('export interface Foo {\n id: string;\n}'); - }); - - it('round-trips a const declaration', () => { - const decl = ts.factory.createVariableStatement( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - 'x', - undefined, - undefined, - ts.factory.createNumericLiteral(1) - ), - ], - ts.NodeFlags.Const - ) - ); - expect(printNodes([decl])).toBe('export const x = 1;'); - }); - - it('joins multiple nodes with a newline', () => { - const lit = (n: number): ts.ExpressionStatement => - ts.factory.createExpressionStatement(ts.factory.createNumericLiteral(n)); - expect(printNodes([lit(1), lit(2)])).toBe('1;\n2;'); - }); - }); - - describe('printStatements', () => { - const lit = (n: number): ts.ExpressionStatement => - ts.factory.createExpressionStatement(ts.factory.createNumericLiteral(n)); - - it('separates top-level declarations with a blank line', () => { - expect(printStatements([lit(1), lit(2)])).toBe('1;\n\n2;'); - }); - - it('prints a single node with no trailing blank line', () => { - expect(printStatements([lit(1)])).toBe('1;'); - }); - }); - - describe('parseStatements', () => { - it('yields re-printable statements from source', () => { - const statements = parseStatements('export const x = 1;'); - expect(statements).toHaveLength(1); - expect(printNodes(statements)).toBe('export const x = 1;'); - }); - }); - - describe('parseExpression', () => { - it('parses a source expression into a re-printable ts.Expression', () => { - const expr = parseExpression('new Blob([])'); - expect(ts.isNewExpression(expr)).toBe(true); - expect(printNodes([expr])).toBe('new Blob([])'); - }); - }); - - describe('literalExpression', () => { - function print(value: unknown): string { - return printStatements([literalExpression(value)]); - } - - it('converts scalars, null, arrays, and objects to expression source', () => { - expect(print('x')).toBe('"x"'); - expect(print(42)).toBe('42'); - expect(print(true)).toBe('true'); - expect(print(false)).toBe('false'); - expect(print(null)).toBe('null'); - expect(print([1, 'a'])).toBe('[1, "a"]'); - expect(print({ a: 1, b: [true] })).toBe('{ a: 1, b: [true] }'); - }); - - it('prints a negative number as a unary minus', () => { - expect(print(-42)).toBe('-42'); - expect(print({ min: -1.5 })).toBe('{ min: -1.5 }'); - }); - - it('quotes object keys that are not identifier-safe', () => { - expect(print({ 'a-b': 1, ok: 2 })).toBe('{ "a-b": 1, ok: 2 }'); - }); - }); - - describe('jsdoc', () => { - it('prints a single-line block comment above the node', () => { - const decl = ts.factory.createVariableStatement( - undefined, - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - 'y', - undefined, - undefined, - ts.factory.createNull() - ), - ], - ts.NodeFlags.Const - ) - ); - const out = printNodes([jsdoc(decl, 'A note.')]); - expect(out).toBe('/**\n * A note.\n */\nconst y = null;'); - }); - - it('prints a multi-line block comment with one star per line', () => { - const decl = ts.factory.createTypeAliasDeclaration( - undefined, - 'T', - undefined, - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) - ); - const out = printNodes([jsdoc(decl, 'line one\nline two')]); - expect(out).toBe('/**\n * line one\n * line two\n */\ntype T = string;'); - }); - - it('escapes an embedded `*/` so a hostile description cannot break out of the comment', () => { - const decl = ts.factory.createTypeAliasDeclaration( - undefined, - 'T', - undefined, - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) - ); - const out = printNodes([jsdoc(decl, 'evil */ ;globalThis.PWNED=1; /*')]); - // The `*/` is neutralized to `*\/`; no live `*/` survives inside the comment body. - expect(out).toContain('evil *\\/ ;globalThis.PWNED=1; /*'); - expect(out).not.toContain('evil */'); - // Exactly one comment-closing `*/` (the real one the printer appends). - expect(out.match(/\*\//g)).toHaveLength(1); - }); - }); -}); diff --git a/packages/client-generator/src/emitters/__tests__/types.test.ts b/packages/client-generator/src/emitters/__tests__/types.test.ts deleted file mode 100644 index 17126026e3..0000000000 --- a/packages/client-generator/src/emitters/__tests__/types.test.ts +++ /dev/null @@ -1,591 +0,0 @@ -import type { PropertyModel, SchemaModel } from '../../intermediate-representation/model.js'; -import { emitClientSingleFile } from '../client-assembly.js'; -import { printNodes } from '../ts.js'; -import { renderSchema, schemaToTypeNode, typesStatements } from '../types.js'; -import { SCALAR, 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' }); - -describe('renderTypes', () => { - it('produces nothing when there are no schemas', () => { - const out = emitPackage(apiModel({ schemas: [] })); - // Two consecutive blank lines would be a sign of an empty types block; check absence. - expect(out).not.toContain('export type T'); - }); - - it('emits each named schema with its description', () => { - const out = emitPackage( - apiModel({ - schemas: [namedSchema('Foo', { kind: 'scalar', scalar: 'string' }, 'a foo')], - }) - ); - expect(out).toContain('/**\n * a foo\n */'); - expect(out).toContain('export type Foo = string;'); - }); - - it('prefers schema description over the named-schema description', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Foo', { kind: 'scalar', scalar: 'string', description: 'inner' }, 'outer'), - ], - }) - ); - expect(out).toContain('/**\n * inner\n */'); - expect(out).not.toContain('outer'); - }); - - it('omits JSDoc when the schema description is whitespace-only', () => { - // Exercises the `!text.trim()` short-circuit inside renderJsDoc. - const out = emitPackage( - apiModel({ - schemas: [namedSchema('Foo', { kind: 'scalar', scalar: 'string' }, ' ')], - }) - ); - expect(out).not.toContain('/** '); - expect(out).toContain('export type Foo = string;'); - }); - - it('trims leading and trailing blank lines from multi-line schema descriptions', () => { - // Exercises both `start++` and `end--` arms of trimLines. - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Foo', { - kind: 'scalar', - scalar: 'string', - description: '\n\nfirst\nsecond\n\n', - }), - ], - }) - ); - expect(out).toContain('/**\n * first\n * second\n */'); - expect(out).not.toMatch(/\/\*\*\n \*\n/); - }); -}); - -describe('renderSchema (and its branches)', () => { - it('renders scalars: string/number/integer/boolean', () => { - expect(renderSchema({ kind: 'scalar', scalar: 'string' })).toBe('string'); - expect(renderSchema({ kind: 'scalar', scalar: 'number' })).toBe('number'); - expect(renderSchema({ kind: 'scalar', scalar: 'integer' })).toBe('number'); - expect(renderSchema({ kind: 'scalar', scalar: 'boolean' })).toBe('boolean'); - }); - - it('renders a ref as the bare name', () => { - expect(renderSchema({ kind: 'ref', name: 'Foo' })).toBe('Foo'); - }); - - it('renders a binary-format string as Blob (file/upload content)', () => { - expect(renderSchema({ kind: 'scalar', scalar: 'string', metadata: { format: 'binary' } })).toBe( - 'Blob' - ); - // `byte` (base64) stays a string; only `binary` is raw content. - expect(renderSchema({ kind: 'scalar', scalar: 'string', metadata: { format: 'byte' } })).toBe( - 'string' - ); - }); - - it('renders string/number/boolean literals correctly', () => { - expect(renderSchema({ kind: 'literal', value: 'hi' })).toBe('"hi"'); - expect(renderSchema({ kind: 'literal', value: 42 })).toBe('42'); - expect(renderSchema({ kind: 'literal', value: true })).toBe('true'); - expect(renderSchema({ kind: 'literal', value: false })).toBe('false'); - }); - - it('renders negative number literals (built via a prefix-minus expression)', () => { - // TypeScript's factory rejects a bare negative numeric literal — it must be a - // unary-minus over a positive literal, which this exercises. - expect(renderSchema({ kind: 'literal', value: -5 })).toBe('-5'); - expect(renderSchema({ kind: 'enum', values: [-1, 2], scalar: 'number' })).toBe('-1 | 2'); - }); - - it('renders single-value enums without parens when wrapped in array (parens=true)', () => { - expect( - renderSchema({ - kind: 'array', - items: { kind: 'enum', values: ['a'], scalar: 'string' }, - }) - ).toBe('"a"[]'); - }); - - it('renders multi-value enums WITH parens when wrapped in array (parens=true)', () => { - expect( - renderSchema({ - kind: 'array', - items: { kind: 'enum', values: ['a', 'b'], scalar: 'string' }, - }) - ).toBe('("a" | "b")[]'); - }); - - it('renders number enums without JSON-quoting them', () => { - expect(renderSchema({ kind: 'enum', values: [1, 2, 3], scalar: 'number' })).toBe('1 | 2 | 3'); - }); - - it('renders boolean enums without JSON-quoting them', () => { - expect(renderSchema({ kind: 'enum', values: [true, false], scalar: 'boolean' })).toBe( - 'true | false' - ); - }); - - it('renders null and unknown', () => { - expect(renderSchema({ kind: 'null' })).toBe('null'); - expect(renderSchema({ kind: 'unknown' })).toBe('unknown'); - }); - - it('renders array of scalars', () => { - expect(renderSchema({ kind: 'array', items: SCALAR })).toBe('string[]'); - }); - - it('parenthesizes unions inside arrays', () => { - expect( - renderSchema({ - kind: 'array', - items: { kind: 'union', members: [SCALAR, { kind: 'null' }] }, - }) - ).toBe('(string | null)[]'); - }); - - it('renders records', () => { - expect(renderSchema({ kind: 'record', value: SCALAR })).toBe('Record'); - }); - - it('renders empty objects as `{}`', () => { - expect(renderSchema({ kind: 'object', properties: [] })).toBe('{}'); - }); - - it('renders required vs optional properties', () => { - const props: PropertyModel[] = [ - { name: 'id', schema: SCALAR, required: true }, - { name: 'name', schema: SCALAR, required: false }, - ]; - const got = renderSchema({ kind: 'object', properties: props }); - expect(got).toContain('id: string;'); - expect(got).toContain('name?: string;'); - }); - - it('renders an inline single-line JSDoc above a property with a short description', () => { - const got = renderSchema({ - kind: 'object', - properties: [{ name: 'a', schema: SCALAR, required: true, description: 'short' }], - }); - expect(got).toContain(' /**\n * short\n */\n a: string;'); - }); - - it('renders an inline multi-line JSDoc above a property with a long description', () => { - const got = renderSchema({ - kind: 'object', - properties: [ - { - name: 'a', - schema: SCALAR, - required: true, - description: 'line1\nline2', - }, - ], - }); - expect(got).toContain(' /**\n * line1\n * line2\n */\n'); - }); - - it('emits a `readonly` modifier on readOnly properties', () => { - // readOnly (server-managed) props are marked `readonly` so consumer write-type - // utilities (e.g. OmitReadOnly) can strip them; non-readOnly props are plain. - const got = renderSchema({ - kind: 'object', - properties: [ - { name: 'id', schema: SCALAR, required: true, readOnly: true }, - { name: 'name', schema: SCALAR, required: true }, - ], - }); - expect(got).toContain('readonly id: string;'); - expect(got).toMatch(/\n {4}name: string;/); - expect(got).not.toContain('readonly name'); - }); - - it('renders an omit schema as Omit', () => { - expect(renderSchema({ kind: 'omit', base: 'Pet', keys: ['id', 'createdAt'] })).toBe( - 'Omit' - ); - }); - - it('renders union and intersection', () => { - expect(renderSchema({ kind: 'union', members: [SCALAR, { kind: 'null' }] })).toBe( - 'string | null' - ); - const inter = renderSchema({ - kind: 'intersection', - members: [ - { kind: 'ref', name: 'A' }, - { kind: 'ref', name: 'B' }, - ], - }); - expect(inter).toBe('A & B'); - }); - - it('quotes property names that contain disallowed characters', () => { - const got = renderSchema({ - kind: 'object', - properties: [{ name: 'menu:read', schema: SCALAR, required: true }], - }); - expect(got).toContain('"menu:read": string;'); - }); - - it('quotes property names that are reserved words', () => { - const got = renderSchema({ - kind: 'object', - properties: [{ name: 'class', schema: SCALAR, required: true }], - }); - expect(got).toContain('"class": string;'); - }); - - it('renders an inline empty-description JSDoc as nothing', () => { - const got = renderSchema({ - kind: 'object', - properties: [{ name: 'a', schema: SCALAR, required: true, description: ' ' }], - }); - // empty description ⇒ no JSDoc and no leading 2-space indent before the prop - expect(got).not.toContain('/**'); - expect(got).toContain(' a: string;'); - }); -}); - -describe('JSDoc validation metadata (@minimum / @maxLength / @pattern / @format / @deprecated)', () => { - it('renders numeric constraints as JSDoc tags on a named schema', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Limit', { - kind: 'scalar', - scalar: 'integer', - metadata: { minimum: 1, maximum: 100 }, - }), - ], - }) - ); - expect(out).toMatch( - /\/\*\*[\s\S]*@minimum 1[\s\S]*@maximum 100[\s\S]*\*\/\s*export type Limit = number;/ - ); - }); - - it('renders string constraints (minLength, maxLength, pattern, format) as JSDoc tags', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Name', { - kind: 'scalar', - scalar: 'string', - metadata: { - minLength: 1, - maxLength: 50, - pattern: '^[A-Z]+$', - format: 'email', - }, - }), - ], - }) - ); - expect(out).toContain('@minLength 1'); - expect(out).toContain('@maxLength 50'); - expect(out).toContain('@pattern ^[A-Z]+$'); - expect(out).toContain('@format email'); - }); - - it('renders array constraints (minItems, maxItems, uniqueItems) as JSDoc tags', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Tags', { - kind: 'array', - items: { kind: 'scalar', scalar: 'string' }, - metadata: { minItems: 1, maxItems: 5, uniqueItems: true }, - }), - ], - }) - ); - expect(out).toContain('@minItems 1'); - expect(out).toContain('@maxItems 5'); - expect(out).toContain('@uniqueItems'); - // No value after @uniqueItems — it's a presence-only tag. - expect(out).not.toContain('@uniqueItems true'); - }); - - it('renders @exclusiveMinimum / @exclusiveMaximum in numeric form', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Volume', { - kind: 'scalar', - scalar: 'number', - metadata: { exclusiveMinimum: 0, exclusiveMaximum: 1000 }, - }), - ], - }) - ); - expect(out).toContain('@exclusiveMinimum 0'); - expect(out).toContain('@exclusiveMaximum 1000'); - }); - - it('renders @deprecated as a presence-only tag', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Old', { - kind: 'scalar', - scalar: 'string', - metadata: { deprecated: true }, - }), - ], - }) - ); - expect(out).toContain('@deprecated'); - expect(out).not.toContain('@deprecated true'); - }); - - it('combines description text and tags in the same JSDoc block', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Limit', { - kind: 'scalar', - scalar: 'integer', - description: 'Page size.', - metadata: { minimum: 1, maximum: 100 }, - }), - ], - }) - ); - // Description first, then tag lines. - expect(out).toMatch(/\*\s*Page size\.\s*\n\s*\*\s*@minimum 1\s*\n\s*\*\s*@maximum 100/); - }); - - it('renders metadata above inline object properties', () => { - const got = renderSchema({ - kind: 'object', - properties: [ - { - name: 'name', - required: true, - description: 'Display name.', - schema: { - kind: 'scalar', - scalar: 'string', - metadata: { minLength: 1, maxLength: 50, pattern: '^[A-Z]+$' }, - }, - }, - ], - }); - // Multi-line JSDoc with description then tags, immediately above the prop line. - expect(got).toMatch(/\* Display name\./); - expect(got).toMatch(/\* @minLength 1/); - expect(got).toMatch(/\* @maxLength 50/); - expect(got).toMatch(/\* @pattern \^\[A-Z\]\+\$/); - expect(got).toContain('name: string;'); - }); - - it('omits the JSDoc block when there is neither description nor metadata', () => { - const got = renderSchema({ - kind: 'object', - properties: [{ name: 'a', schema: SCALAR, required: true }], - }); - expect(got).not.toContain('/**'); - }); - - it('emits a JSDoc block when metadata exists even without a description', () => { - const got = renderSchema({ - kind: 'object', - properties: [ - { - name: 'page', - required: true, - schema: { - kind: 'scalar', - scalar: 'integer', - metadata: { minimum: 1 }, - }, - }, - ], - }); - expect(got).toMatch(/\/\*\*\n {5}\* @minimum 1\n {5}\*\/\n {4}page: number;/); - }); - - it('escapes `*/` inside pattern strings so it cannot terminate the JSDoc block', () => { - // Defensive guard: a regex pattern of `^a*/b$` would otherwise break the comment. - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Tricky', { - kind: 'scalar', - scalar: 'string', - metadata: { pattern: '^a*/b$' }, - }), - ], - }) - ); - expect(out).toContain('@pattern ^a*\\/b$'); - expect(out).not.toContain('@pattern ^a*/b$'); - }); - - it('does not emit a JSDoc block when metadata bag is present but empty', () => { - // We never produce `{}` from the builder, but harden the renderer anyway. - const got = renderSchema({ - kind: 'object', - properties: [{ name: 'a', schema: { ...SCALAR, metadata: {} }, required: true }], - }); - expect(got).not.toContain('/**'); - }); -}); - -describe('dateType knob (string → Date for date formats)', () => { - const dateTime = (): SchemaModel => ({ - kind: 'scalar', - scalar: 'string', - metadata: { format: 'date-time' }, - }); - - it('emits Date for a date-time string scalar under dateType "Date"', () => { - expect(renderSchema(dateTime(), 'Date')).toBe('Date'); - }); - - it('emits Date for a date string scalar under dateType "Date"', () => { - expect( - renderSchema({ kind: 'scalar', scalar: 'string', metadata: { format: 'date' } }, 'Date') - ).toBe('Date'); - }); - - it('keeps string for date-time under dateType "string"', () => { - expect(renderSchema(dateTime(), 'string')).toBe('string'); - }); - - it('keeps string for date-time by default (omitted dateType — byte-identical)', () => { - expect(renderSchema(dateTime())).toBe('string'); - }); - - it('keeps string for a non-date string format regardless of dateType', () => { - const email: SchemaModel = { kind: 'scalar', scalar: 'string', metadata: { format: 'email' } }; - expect(renderSchema(email, 'Date')).toBe('string'); - }); - - it('leaves non-string scalars unaffected under dateType "Date"', () => { - expect(renderSchema({ kind: 'scalar', scalar: 'integer' }, 'Date')).toBe('number'); - }); - - it('threads Date into nested object properties and arrays under "Date"', () => { - const out = renderSchema( - { - kind: 'object', - properties: [ - { name: 'createdAt', schema: dateTime(), required: true }, - { name: 'days', schema: { kind: 'array', items: dateTime() }, required: false }, - ], - }, - 'Date' - ); - expect(out).toContain('createdAt: Date;'); - expect(out).toContain('days?: Date[];'); - }); - - it('emits Date in the named-schema alias body under emitOptions dateType "Date"', () => { - const out = emitPackage(apiModel({ schemas: [namedSchema('Created', dateTime())] }), { - dateType: 'Date', - }); - expect(out).toContain('export type Created = Date;'); - }); - - it('leaves the named-schema alias as string by default', () => { - const out = emitPackage(apiModel({ schemas: [namedSchema('Created', dateTime())] })); - expect(out).toContain('export type Created = string;'); - }); - - it('defaults schemaToTypeNode dateType to string (called with one arg)', () => { - expect(printNodes([schemaToTypeNode(dateTime())])).toBe('string'); - }); - - it('defaults typesStatements dateType to string (called without it)', () => { - const out = printNodes(typesStatements([namedSchema('Created', dateTime())])); - expect(out).toContain('export type Created = string;'); - }); -}); - -describe('enum style — const-object companion (C6.2)', () => { - const orderStatus = namedSchema('OrderStatus', { - kind: 'enum', - scalar: 'string', - values: ['placed', 'completed'], - }); - - it('emits a const-object companion for named string enums by default', () => { - const out = emitPackage(apiModel({ schemas: [orderStatus] })); - expect(out).toContain('export type OrderStatus = "placed" | "completed";'); - expect(out).toContain('export const OrderStatus = {'); - expect(out).toContain('placed: "placed",'); - expect(out).toContain('completed: "completed"'); - expect(out).toContain('} as const;'); - }); - - it('does not emit a const object for integer enums', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Code', { - kind: 'enum', - scalar: 'integer', - values: [1, 2], - }), - ], - }) - ); - expect(out).toContain('export type Code = 1 | 2;'); - expect(out).not.toContain('export const Code'); - }); - - it('does not emit a const object for boolean enums', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Flag', { - kind: 'enum', - scalar: 'boolean', - values: [true, false], - }), - ], - }) - ); - expect(out).not.toContain('export const Flag'); - }); - - it('skips the const object when any value is not a valid identifier', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Scope', { - kind: 'enum', - scalar: 'string', - values: ['menu:read', 'menuWrite'], - }), - ], - }) - ); - expect(out).toContain('export type Scope = "menu:read" | "menuWrite";'); - expect(out).not.toContain('export const Scope'); - }); - - it('skips the const object for a string-scalar enum that contains a non-string value', () => { - // scalarForEnumValues can return 'string' for a mixed enum; the const-object - // path must still bail when a value isn't actually a string. - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Mixed', { - kind: 'enum', - scalar: 'string', - values: ['a', 1], - }), - ], - }) - ); - expect(out).not.toContain('export const Mixed'); - }); -}); diff --git a/packages/client-generator/src/emitters/__tests__/zod.test.ts b/packages/client-generator/src/emitters/__tests__/zod.test.ts index 48334a09da..82aecf3898 100644 --- a/packages/client-generator/src/emitters/__tests__/zod.test.ts +++ b/packages/client-generator/src/emitters/__tests__/zod.test.ts @@ -1,5 +1,4 @@ import type { NamedSchemaModel, SchemaModel } from '../../intermediate-representation/model.js'; -import { printStatements } from '../ts.js'; import { renderZodModule, schemaToZodExpression } from '../zod.js'; import { apiModel, operation, response } from './fixtures.js'; @@ -438,8 +437,64 @@ describe('renderZodModule — operation validation surface', () => { }); describe('schemaToZodExpression — direct export', () => { - it('is callable directly and returns an expression node', () => { - const node = schemaToZodExpression({ kind: 'scalar', scalar: 'string' }); - expect(printStatements([node])).toBe('z.string()'); + it('is callable directly and returns the expression source text', () => { + expect(schemaToZodExpression({ kind: 'scalar', scalar: 'string' })).toBe('z.string()'); + }); +}); + +describe('erasable TypeScript', () => { + // The generated CLI imports this module and runs under `node + // --experimental-strip-types`, which rejects anything needing a transform. + const out = renderZodModule( + apiModel({ + schemas: [ + { + name: 'Order', + schema: { + kind: 'object', + properties: [ + { name: 'id', schema: { kind: 'scalar', scalar: 'string' }, required: true }, + ], + }, + }, + ], + services: [ + { + name: 'Orders', + operations: [ + operation({ + name: 'createOrder', + method: 'post', + requestBody: { + contentType: 'application/json', + required: true, + schema: { kind: 'ref', name: 'Order' }, + }, + successResponses: [response({ schema: { kind: 'ref', name: 'Order' } })], + }), + ], + }, + ], + }) + ); + + it('declares error fields instead of using constructor parameter properties', () => { + expect(out).toContain('class ZodValidationError'); + // `constructor(readonly x: string)` fails strip-only mode. + expect(out).not.toMatch(/constructor\([^)]*\breadonly\b/s); + expect(out).toContain('readonly operationId: string;'); + expect(out).toContain('this.operationId = operationId;'); + }); + + it('emits no construct that type stripping cannot erase', () => { + for (const construct of [ + /\benum /, + /\bnamespace /, + /\bdeclare /, + /\bprivate /, + /\bprotected /, + ]) { + expect(out).not.toMatch(construct); + } }); }); diff --git a/packages/client-generator/src/emitters/auth.ts b/packages/client-generator/src/emitters/auth.ts deleted file mode 100644 index 483dc7a6f4..0000000000 --- a/packages/client-generator/src/emitters/auth.ts +++ /dev/null @@ -1,33 +0,0 @@ -// The auth *naming* conventions of the generated surface. Credential injection itself -// lives in the runtime (src/runtime/auth.ts); the wiring emitter only derives the -// public setter names bound to the client instance's `auth` members. - -import type { SecuritySchemeModel } from '../intermediate-representation/model.js'; -import { pascalCase } from './support.js'; - -/** - * Public setter name for an apiKey scheme: `setApiKey` when it's the only apiKey - * scheme (of any `in`), else `setApiKey` to disambiguate. - */ -export function apiKeySetterName(key: string, sole: boolean): string { - return sole ? 'setApiKey' : `setApiKey${pascalCase(key)}`; -} - -/** - * The public credential-setter names the client exports for a set of schemes, - * in emission order (`setBearer`, then `setBasicAuth`, then each apiKey setter). - * Also seeds the reserved-identifier set (`packageIdents`) so operation names - * can't collide with a setter. - */ -export function authSetterNames(schemes: SecuritySchemeModel[]): string[] { - const names: string[] = []; - if (schemes.some((s) => s.kind === 'bearer')) names.push('setBearer'); - if (schemes.some((s) => s.kind === 'basic')) names.push('setBasicAuth'); - const apiKeySchemes = schemes.filter( - (s) => s.kind === 'apiKeyHeader' || s.kind === 'apiKeyQuery' || s.kind === 'apiKeyCookie' - ); - for (const scheme of apiKeySchemes) { - names.push(apiKeySetterName(scheme.key, apiKeySchemes.length === 1)); - } - return names; -} diff --git a/packages/client-generator/src/emitters/cli-docs.ts b/packages/client-generator/src/emitters/cli-docs.ts new file mode 100644 index 0000000000..5fbe7f8850 --- /dev/null +++ b/packages/client-generator/src/emitters/cli-docs.ts @@ -0,0 +1,215 @@ +// The cli-docs emitter: renders the Markdown reference for the generated CLI from the +// SAME command table `runCli` dispatches on, and the same `groupSlug`/`constantCase` the +// 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'; + +export type CliDocsOptions = { + /** Page heading. */ + title: string; + /** Emit YAML front matter carrying the title, for docs sites that expect it. */ + frontmatter: boolean; + /** The generated file's stem: what the page calls the command, and what its credential + * variables derive from. A reader who installs it under another bin name renames only + * the command — the variables are fixed at generation. */ + name: string; + /** Auth schemes the description declares, in the order the CLI resolves them. */ + schemes: Array<{ key: string; kind: 'bearer' | 'basic' | 'apiKey' }>; +}; + +/** Table-cell-safe text: one line, and pipes/backslashes escaped so they don't alter columns/escaping. */ +function cell(text: string | undefined): string { + return (text ?? '').replace(/\s+/g, ' ').trim().replace(/\\/g, '\\\\').replace(/\|/g, '\\|'); +} + +/** How a command is typed at the prompt: ` `, or just ``. */ +function address(command: CliCommand): string { + return [command.group === undefined ? undefined : groupSlug(command.group), command.name] + .filter(Boolean) + .join(' '); +} + +function usageLine(name: string, command: CliCommand): string { + const words = [ + name, + address(command), + ...command.positionals.map((positional) => `<${positional.name}>`), + ...command.flags.filter((flag) => flag.required).map((flag) => `--${flag.name} <${flag.type}>`), + ...(command.body ? [command.body.required ? "--json ''" : "[--json '']"] : []), + ]; + return words.filter((word) => word !== '').join(' '); +} + +function writeFlagTable(printer: Printer, flags: CliFlag[]): void { + printer.line('| Flag | Type | Required | Description |'); + printer.line('| ---- | ---- | -------- | ----------- |'); + for (const flag of flags) { + const description = [ + cell(flag.description), + flag.enum === undefined + ? '' + : `One of ${flag.enum.map((value) => `\`${value}\``).join(', ')}.`, + flag.type === 'array' ? 'Repeat the flag for multiple values.' : '', + ] + .filter((part) => part !== '') + .join(' '); + printer.line( + `| \`--${flag.name}\` | ${flag.type} | ${flag.required ? 'yes' : 'no'} | ${description} |` + ); + } + printer.blank(); +} + +function writeCommand(printer: Printer, command: CliCommand, options: CliDocsOptions): void { + printer.line(`### \`${address(command)}\``); + printer.blank(); + if (command.summary !== undefined) { + printer.line(cell(command.summary)); + printer.blank(); + } + printer.line(`\`${command.method} ${command.path}\``); + printer.blank(); + printer.line('```sh'); + printer.line(usageLine(options.name, command)); + printer.line('```'); + printer.blank(); + if (command.positionals.length > 0) { + printer.line('| Argument | Description |'); + printer.line('| -------- | ----------- |'); + for (const positional of command.positionals) { + printer.line(`| \`<${positional.name}>\` | ${cell(positional.description)} |`); + } + printer.blank(); + } + if (command.flags.length > 0) writeFlagTable(printer, command.flags); + const notes = [ + command.body === undefined + ? '' + : `Takes a JSON body${command.body.required ? ' (required)' : ''}: \`--json ''\`, \`--json @file.json\`, or \`--json @-\` for stdin.`, + command.unsupportedBody === undefined + ? '' + : `Takes a \`${command.unsupportedBody}\` body, which the CLI cannot build — call this operation through the generated client instead.`, + command.paginated === true + ? 'Paginated: `--page-all` follows every page, printing one JSON page per line.' + : '', + command.sse === true ? 'Streams server-sent events as one JSON object per line.' : '', + command.blob === true ? 'Returns binary content, so `--output ` is required.' : '', + ].filter((note) => note !== ''); + for (const note of notes) printer.line(note); + if (notes.length > 0) printer.blank(); +} + +/** The whole page: heading, global flags, credentials, exit codes, then every command. */ +export function renderCliDocs(commands: CliCommand[], options: CliDocsOptions): string { + const printer = new Printer(); + if (options.frontmatter) { + printer.line('---'); + printer.line(`title: ${options.title}`); + printer.line('---'); + printer.blank(); + } + printer.line(`# ${options.title}`); + printer.blank(); + printer.line( + `Generated command-line reference for \`${options.name}\`, produced from the API description by \`redocly generate-client\`.` + ); + printer.line('Re-run generation to update it — this file is not hand-edited.'); + printer.blank(); + + printer.line('## Usage'); + printer.blank(); + printer.line('```sh'); + printer.line(`${options.name} [flags]`); + printer.line(`${options.name} --help`); + printer.line(`${options.name} schema # request/response schemas`); + printer.line('```'); + printer.blank(); + printer.line( + 'Install the file under any `bin` name: the command takes that name, and the credential variables below do not change.' + ); + printer.blank(); + + printer.line('## Global flags'); + printer.blank(); + printer.line('| Flag | Description |'); + printer.line('| ---- | ----------- |'); + // `--token` mirrors the CLI itself: without a bearer scheme the tool rejects the flag, + // so the reference must not list it. + const hasBearer = options.schemes.some((scheme) => scheme.kind === 'bearer'); + for (const [flag, description] of [ + ['--server-url ', 'Override the server URL included in the client.'], + ['--format ', 'Output format.'], + ['--dry-run', 'Print the prepared request, credentials redacted, without sending it.'], + ['--page-all', 'Follow pagination, printing one JSON page per line.'], + ['--output ', 'Write the response body to a file. Required for binary responses.'], + ...(hasBearer ? [['--token ', 'Bearer token, overriding the environment.']] : []), + ['--json ', 'Request body, inline or from a file or stdin.'], + ] as const) { + printer.line(`| \`${flag}\` | ${description} |`); + } + printer.blank(); + + const prefix = constantCase(options.name); + printer.line('## Credentials'); + printer.blank(); + if (options.schemes.length === 0) { + printer.line('The description declares no security schemes, so no credentials are read.'); + } else { + printer.line('Credentials come from the environment:'); + printer.blank(); + printer.line('| Scheme | Variable |'); + printer.line('| ------ | -------- |'); + for (const scheme of options.schemes) { + const variable = + scheme.kind === 'bearer' + ? `\`${prefix}_TOKEN\` (or \`--token\`)` + : scheme.kind === 'basic' + ? `\`${prefix}_USERNAME\` and \`${prefix}_PASSWORD\`` + : `\`${prefix}_API_KEY_${constantCase(scheme.key)}\``; + printer.line(`| ${scheme.kind} (\`${scheme.key}\`) | ${variable} |`); + } + } + printer.blank(); + + printer.line('## Exit codes'); + printer.blank(); + printer.line('| Code | Meaning |'); + printer.line('| ---- | ------- |'); + for (const [code, meaning] of [ + [0, 'success'], + [1, 'API error (status other than 401 or 403)'], + [2, 'auth error (401 or 403)'], + [3, 'validation error'], + [4, 'usage error (unknown command or flag, bad `--json`)'], + ] as const) { + printer.line(`| ${code} | ${meaning} |`); + } + printer.blank(); + printer.line('Errors print one JSON object to stderr, so stdout stays clean for piping.'); + printer.blank(); + + // One section per tag, in the order the description declares them, then the untagged + // commands — the same order `--help` lists them in. + const groups = [...new Set(commands.map((command) => command.group))]; + for (const group of groups) { + const inGroup = commands.filter((command) => command.group === group); + if (group === undefined) { + printer.line('## Commands'); + printer.blank(); + } else { + printer.line(`## ${group}`); + printer.blank(); + printer.line(`Addressed as \`${options.name} ${groupSlug(group)} \`.`); + printer.blank(); + } + for (const command of inGroup) writeCommand(printer, command, options); + } + return ( + printer + .toString() + .replace(/\n{3,}/g, '\n\n') + .trimEnd() + '\n' + ); +} diff --git a/packages/client-generator/src/emitters/cli.ts b/packages/client-generator/src/emitters/cli.ts new file mode 100644 index 0000000000..dd34734e7f --- /dev/null +++ b/packages/client-generator/src/emitters/cli.ts @@ -0,0 +1,346 @@ +// The cli emitter: derives pure `CliCommand[]` data from the IR and renders +// `.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 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'; + +function kebab(name: string): string { + return casing.snake(name).replace(/_/g, '-'); +} + +function flagFor(param: ParamModel): CliFlag { + const schema = param.schema; + const type: CliFlag['type'] = + schema.kind === 'array' + ? 'array' + : schema.kind === 'scalar' && (schema.scalar === 'integer' || schema.scalar === 'number') + ? 'number' + : schema.kind === 'scalar' && schema.scalar === 'boolean' + ? 'boolean' + : 'string'; + return { + name: kebab(param.name), + param: param.name, + type, + required: param.required, + ...(schema.kind === 'enum' ? { enum: schema.values.map(String) } : {}), + ...(param.description !== undefined ? { description: param.description } : {}), + }; +} + +/** Mirrors `computeResponse`: a blob operation has binary success content and no JSON alternative. */ +function isBlobOp(op: OperationModel): boolean { + const responses = op.successResponses; + if (responses.some((response) => response.contentType.toLowerCase().includes('json'))) { + return false; + } + return responses.some( + (response) => + response.contentType.startsWith('image/') || + response.contentType === 'application/octet-stream' + ); +} + +function jsonSuccessSchema(op: OperationModel): SchemaModel | undefined { + return op.successResponses.find((response) => response.contentType.toLowerCase().includes('json')) + ?.schema; +} + +/** + * Whether a flat-style call spells this operation's body as its own properties — the same + * decision the client's types make, so the dispatcher never has to guess from a value. + */ +function mergedBodyFlag( + op: OperationModel, + model: ApiModel, + argsStyle: 'grouped' | 'flat' | undefined +): { merged?: true } { + if (argsStyle !== 'flat') return {}; + const shape = flatInputShape(op, model.schemas); + return 'mergeBody' in shape && shape.mergeBody ? { merged: true } : {}; +} + +/** + * The operations a flat-style run still addresses by layer: their merged names would + * collide, so the client's own input type keeps the namespaced shape and the dispatcher + * has to build that shape too. + */ +function groupedInputFlag( + op: OperationModel, + model: ApiModel, + argsStyle: 'grouped' | 'flat' | undefined +): { argsStyle?: 'grouped' } { + if (argsStyle !== 'flat') return {}; + return 'collisions' in flatInputShape(op, model.schemas) ? { argsStyle: 'grouped' } : {}; +} + +/** Every operation as pure command data — the table `runCli` interprets. */ +export function commandData( + model: ApiModel, + emit: { pagination?: PaginationConfig; argsStyle?: 'grouped' | 'flat' } +): CliCommand[] { + const commands: CliCommand[] = []; + for (const service of model.services) { + for (const op of service.operations) { + const jsonBody = op.requestBody?.contentType.toLowerCase().includes('json') + ? op.requestBody + : undefined; + const responseSchema = jsonSuccessSchema(op); + commands.push({ + ...(op.tags.length > 0 ? { group: op.tags[0] } : {}), + name: op.name, + ...(op.summary !== undefined ? { summary: op.summary } : {}), + method: op.method.toUpperCase(), + path: op.path, + positionals: op.pathParams.map((param) => ({ + name: param.name, + type: flagFor(param).type, + ...(param.description !== undefined ? { description: param.description } : {}), + })), + flags: op.queryParams.map(flagFor), + ...(jsonBody + ? { body: { required: jsonBody.required, ...mergedBodyFlag(op, model, emit.argsStyle) } } + : {}), + ...(jsonBody === undefined && op.requestBody !== undefined + ? { unsupportedBody: op.requestBody.contentType } + : {}), + ...(resolveOperationPagination(op, model, emit.pagination).spec !== undefined + ? { paginated: true } + : {}), + ...groupedInputFlag(op, model, emit.argsStyle), + ...(isSseOp(op) ? { sse: true } : {}), + ...(isBlobOp(op) ? { blob: true } : {}), + ...(jsonBody !== undefined || responseSchema !== undefined + ? { + schemas: { + ...(jsonBody ? { request: jsonBody.schema } : {}), + ...(responseSchema !== undefined ? { response: responseSchema } : {}), + }, + } + : {}), + }); + } + } + return commands; +} + +/** + * The self-execution guard both generated entries share. Realpath on both sides: some + * runners resolve symlinks in `import.meta.url` but not in `argv[1]` (macOS temp dirs, + * installed bin symlinks); the catch covers an entry that is not a file (REPL, node -e). + */ +const ENTRY_GUARD = `function isProcessEntry(): boolean { + if (process.argv[1] === undefined) return false; + try { + return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); + } catch { + return false; + } +} +if (isProcessEntry()) { + process.exit(await run()); +}`; + +/** JSON as a TS expression: U+2028/U+2029 are line terminators in code contexts. */ +function codeJson(value: unknown, indent?: number): string { + return JSON.stringify(value, null, indent) + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); +} + +export type CliModuleOptions = { + stem: string; + importExt: string; + runtime: 'inline' | 'package'; + zodSelected: boolean; + pagination?: PaginationConfig; + /** The sibling client's call shape, which the dispatcher builds its inputs for. */ + argsStyle?: 'grouped' | 'flat'; +}; + +/** + * The auth schemes as the CLI sees them: every apiKey placement is one `apiKey` kind, + * since the credential is read from the same env variable either way. Exported so the + * docs generator names the same variables the runtime reads. + */ +export function cliAuthSchemes(model: ApiModel): CliAuthScheme[] { + return model.securitySchemes.map((scheme) => ({ + key: scheme.key, + kind: scheme.kind === 'bearer' || scheme.kind === 'basic' ? scheme.kind : 'apiKey', + })); +} + +/** How an operation named after a tag is reached — the two halves of `parseInvocation`. */ +function shadowedAddress(command: CliCommand): string { + return command.group === undefined + ? `${command.name} (keeps the bare word, so the "${command.name}" group has no help page)` + : `${command.name} (run it as "${groupSlug(command.group)} ${command.name}")`; +} + +/** + * A leading group name is read as the group, so an operation whose name is also a tag name + * resolves unusually: a tagged one loses the bare form and runs as ` `, + * and an untagged one keeps the bare form and hides that group's help. Nothing becomes + * unreachable either way, but only the description's author can rename a side of the + * collision, so say it once at generation time. + */ +function warnShadowedCommands(commands: CliCommand[]): void { + const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string))); + const shadowed = commands.filter((command) => slugs.has(command.name)); + if (shadowed.length === 0) return; + logger.warn( + `generate-client: cli reads a leading group name as the group, so ${shadowed.length} operation(s) named after a tag resolve unusually — rename the operation or the tag: ${shadowed + .map(shadowedAddress) + .join(', ')}.\n` + ); +} + +/** The whole `.cli.ts` file. */ +export function renderCliModule(model: ApiModel, options: CliModuleOptions): string { + const commands = commandData(model, { + pagination: options.pagination, + argsStyle: options.argsStyle, + }); + warnShadowedCommands(commands); + const schemes = cliAuthSchemes(model); + const clientModule = `./${options.stem}.${options.importExt}`; + const clientImports = ['client', 'configure', ...(options.zodSelected ? ['use'] : [])]; + + const parts = [ + '#!/usr/bin/env node', + 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()] + : []), + `export const COMMANDS: CliCommand[] = ${codeJson(commands, 2)};`, + ...(options.zodSelected + ? [ + // A dry run never sends the request, so its "response" is the stub the dry-run + // fetch returns — validating that reports drift that does not exist. Request + // validation still runs, which is what makes `--dry-run` a useful preflight. + `use(zodValidation(process.argv.includes("--dry-run") ? { response: false } : {}));`, + ] + : []), + `export const wiring: CliWiring = { + name: invokedName(process.argv[1], ${codeJson(options.stem)}), + envPrefix: ${codeJson(constantCase(options.stem))}, + client, +${options.argsStyle === 'flat' ? ' argsStyle: "flat",\n' : ''} configure, + schemes: ${codeJson(schemes)}, + env: process.env, + stdin: () => readFileSync(0, "utf-8"), + readFile: (path: string) => readFileSync(path, "utf-8"), + writeFile: (path: string, data: Uint8Array) => writeFileSync(path, data), + stdout: (line: string) => console.log(line), + stderr: (line: string) => console.error(line), +}; + +/** Run this CLI programmatically; defaults to the process argv. */ +export const run = (argv: string[] = process.argv.slice(2)): Promise => + runCli(COMMANDS, wiring, argv); + +// Re-exported so a composed entry can run these commands without its own runtime copy. +export { runCli }; + +// Self-execute only as the process entry, so importing this module is side-effect-safe: +// composed binaries and login-style wrappers import COMMANDS/wiring/run instead of +// editing this generated file. +${ENTRY_GUARD}`, + ]; + return parts.join('\n\n') + '\n'; +} + +export type ComposedCliSource = { + /** The api alias from `apis:` — it becomes the namespace the shell types. */ + alias: string; + /** Relative specifier of that api's generated cli module, extension included. */ + modulePath: string; +}; + +/** + * The composed entry `client.cliOutput` produces: one binary over every api that selected + * `cli`, each behind its alias as the namespace, with `_` credential + * prefixes. It imports `runCli` from the first source's module — generated code, so the + * inline runtime's zero-dependency promise holds — and exports `SOURCES` so an adopter + * layers custom commands (a `login`) around it without editing a generated file. + */ +export function renderComposedCliEntry(sources: ComposedCliSource[], stem: string): string { + const prefix = constantCase(stem); + // An identifier can't start with a digit, and two aliases can sanitize identically — + // the underscore and the index keep every import binding legal and unique. + const idents = new Map(); + sources.forEach(({ alias }, index) => { + const sanitized = alias.replace(/[^A-Za-z0-9]/g, '_'); + const legal = /^[A-Za-z_]/.test(sanitized) ? sanitized : `_${sanitized}`; + idents.set(alias, [...idents.values()].includes(legal) ? `${legal}_${index}` : legal); + }); + const identFor = (alias: string): string => idents.get(alias)!; + const imports = sources.map(({ alias, modulePath }, index) => { + const ident = identFor(alias); + const runtime = index === 0 ? ', runCli' : ''; + return `import { COMMANDS as ${ident}Commands, wiring as ${ident}Wiring${runtime} } from ${JSON.stringify(modulePath)};`; + }); + const entries = sources.map(({ alias }) => { + const ident = identFor(alias); + const namespace = kebab(alias); + const aliasPrefix = `${prefix}_${constantCase(alias)}`; + return ` { + namespace: ${JSON.stringify(namespace)}, + commands: ${ident}Commands, + wiring: { ...${ident}Wiring, envPrefix: ${JSON.stringify(aliasPrefix)} }, + },`; + }); + return ( + [ + '#!/usr/bin/env node', + HEADER, + [ + 'import { realpathSync } from "node:fs";', + 'import { fileURLToPath } from "node:url";', + ...imports, + ].join('\n'), + `/** The composed sources — import SOURCES to layer custom commands around this binary. */ +export const SOURCES = [ +${entries.join('\n')} +]; + +/** Run the composed CLI programmatically; defaults to the process argv. */ +export const run = (argv: string[] = process.argv.slice(2)): Promise => + runCli(SOURCES, argv); + +${ENTRY_GUARD}`, + ].join('\n\n') + '\n' + ); +} diff --git a/packages/client-generator/src/emitters/client-assembly.ts b/packages/client-generator/src/emitters/client-assembly.ts index 41859cf70e..b47f7c9693 100644 --- a/packages/client-generator/src/emitters/client-assembly.ts +++ b/packages/client-generator/src/emitters/client-assembly.ts @@ -1,6 +1,6 @@ // Client assembly, shared by both runtime distributions and both output modes. The -// wiring (descriptor map + `Ops` interface, emitters/descriptor.ts) is identical; only -// the runtime block differs — `runtime: 'package'` imports `createClient` from +// 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 → @@ -8,39 +8,24 @@ // (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`). +// Text templates throughout — no `typescript` at generate time. import { allOperations, type ApiModel, type OperationModel, - type SecuritySchemeModel, } from '../intermediate-representation/model.js'; -import { apiKeySetterName } from './auth.js'; -import { descriptorStatements, opsInterfaceStatements, packageIdents } from './descriptor.js'; +import { packageIdents, renderDescriptors } from './descriptor.js'; import { banner, type EmitOptions, HEADER, renderTitleComment } from './emit-options.js'; -import { codeString, isIdentifier } from './identifier.js'; +import { codeString } from './identifier.js'; import { assembleInlineRuntime } from './inline-runtime.js'; -import { renderOperationAliases, sseAliases } from './operation-aliases.js'; -import { operationSignature } from './operation-signature.js'; -import { computeResponse, errorTypeNodes, isTypedMultipart } from './operation-types.js'; -import { type EmitContext, renderArgList } from './operations.js'; +import { isTypedMultipart } from './operation-types.js'; +import type { EmitContext } from './operations.js'; import { resolveModelPagination } from './pagination.js'; -import { responseHeadersTypeLiteral } from './response-headers.js'; +import { collectEntrySchemaRefs, renderAliases, renderOpsType } from './render-client.js'; import { isSseOp } from './sse.js'; -import { pascalCase } from './support.js'; -import { - arrow, - exportConstStatement, - parseStatements, - printNodes, - printStatements, - ts, - typedArrow, -} from './ts.js'; -import { typeGuardStatements } from './type-guards.js'; -import { typesStatements } from './types.js'; - -const { factory } = ts; +import { renderTypeAliases } from './ts-type.js'; +import { renderTypeGuards } from './type-guards.js'; const PACKAGE_SPECIFIER = '@redocly/client-generator'; @@ -76,31 +61,27 @@ function emitClient( // before any statement is built — one aggregated error for the whole model. const pagination = resolveModelPagination(model, options.pagination); const ctx: EmitContext = { - argsStyle: options.argsStyle ?? 'flat', + argsStyle: options.argsStyle ?? 'grouped', errorMode: options.errorMode ?? 'throw', dateType: options.dateType ?? 'string', schemaNames: new Set(model.schemas.map((s) => s.name)), schemas: model.schemas, pagination, }; - const flat = ctx.argsStyle === 'flat'; const hasSse = ops.some(isSseOp); const hasRegular = ops.some((op) => !isSseOp(op)); - const apiKeySchemes = model.securitySchemes.filter( - (s) => s.kind === 'apiKeyHeader' || s.kind === 'apiKeyQuery' || s.kind === 'apiKeyCookie' - ); const wiring = ops.length > 0 ? [ - ...opsInterfaceStatements(model, idents, ctx), - ...descriptorStatements(model, idents, ctx.dateType, pagination), + renderOpsType(model, idents, ctx), + renderDescriptors(model, idents, ctx.dateType, pagination, ctx.argsStyle), ] : // A spec with no operations still gets the uniform wiring shape. - parseStatements( - 'export type Ops = Record;\n' + - 'export const OPERATIONS = {} as const satisfies Record;' - ); + [ + 'export type Ops = Record;', + 'export const OPERATIONS = {} as const satisfies Record;', + ]; const runtimeSection = embed ? assembleInlineRuntime({ @@ -112,18 +93,17 @@ function emitClient( setup: !!options.setup, paginate: pagination.size > 0, }) - : importLine(options, ctx, { - hasFlatSse: hasSse && flat, - hasFlatRegular: hasRegular && flat, - hasRegular, - hasApiKey: apiKeySchemes.length > 0, - }); - const schemaStatements = [ - ...typesStatements(model.schemas, ctx.dateType), - ...typeGuardStatements(model.schemas), - ]; - const bodyStatements = [...ops.flatMap((op) => aliasStatements(op, ctx)), ...wiring]; - const sugar = printNodes(sugarStatements(ops, idents, ctx, model.securitySchemes, apiKeySchemes)); + : importLine(options, ctx, { hasRegular }); + const schemaSection = [ + renderTypeAliases(model.schemas, ctx.dateType), + renderTypeGuards(model.schemas), + ] + .filter((section) => section.length > 0) + .join('\n\n'); + const bodySection = [...ops.map((op) => renderAliases(op, ctx)), ...wiring] + .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); @@ -138,7 +118,7 @@ function emitClient( HEADER, renderTitleComment(model), ...(embed ? [] : [runtimeSection]), - printStatements([...schemaStatements, ...bodyStatements]), + [schemaSection, bodySection].filter((section) => section.length > 0).join('\n\n'), ...(embed ? [runtimeSection] : []), clientSection(options, ctx, model), sugar, @@ -147,106 +127,51 @@ function emitClient( }; } - const body = printStatements(bodyStatements); - const hasSchemas = schemaStatements.length > 0; + const hasSchemas = schemaSection.length > 0; return { entry: banner([ HEADER, renderTitleComment(model), hasSchemas - ? schemaLinks( - body + '\n' + sugar, - ctx.schemaNames, - `./${splitStem}.schemas.${options.importExt ?? 'js'}` - ) + ? schemaLinks(model, ctx, `./${splitStem}.schemas.${options.importExt ?? 'js'}`) : '', ...(embed ? [] : [runtimeSection]), - body, + bodySection, ...(embed ? [runtimeSection] : []), clientSection(options, ctx, model), sugar, reexports, ]), - schemas: hasSchemas - ? banner([HEADER, renderTitleComment(model), printStatements(schemaStatements)]) - : undefined, + schemas: hasSchemas ? banner([HEADER, renderTitleComment(model), schemaSection]) : undefined, }; } /** * The entry ⇄ schemas linkage of the split layout: a type-only import of exactly the - * schema names the entry's own code references, plus the public `export *` re-export. - * Referenced names are found by walking the printed entry code's identifiers (an AST - * pass over the emitted text, not a substring search — operation JSDoc may mention a - * schema name, and importing an unreferenced type would trip `noUnusedLocals`). + * schema names the entry's own code references (derived from the IR — the same + * sources the alias/Ops renderers type), plus the public `export *` re-export. */ -function schemaLinks(entryCode: string, schemaNames: Set, specifier: string): string { - const referenced = new Set(); - // Only TYPE references count as uses of the type-only import: a value-position - // identifier that happens to share a schema's name (every descriptor's `id:` key, - // for a schema named `id`) must not drag the name in — strict consumer lint - // configs flag the resulting unused import. - const visit = (node: ts.Node): void => { - if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { - if (schemaNames.has(node.typeName.text)) referenced.add(node.typeName.text); - } - node.forEachChild(visit); - }; - for (const statement of parseStatements(entryCode)) visit(statement); - const names = [...referenced].sort(); +function schemaLinks(model: ApiModel, ctx: EmitContext, specifier: string): string { + const names = collectEntrySchemaRefs(model, ctx); const importLine = names.length > 0 ? `import type { ${names.join(', ')} } from '${specifier}';\n` : ''; 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: { hasFlatSse: boolean; hasFlatRegular: boolean; hasRegular: boolean; hasApiKey: boolean } -): string { +function importLine(options: EmitOptions, ctx: EmitContext, refs: { hasRegular: boolean }): string { const values = ['createClient', ...(options.setup ? ['mergeSetup'] : [])]; const types = [ ...(options.setup ? ['ClientConfig', 'Middleware'] : []), 'OperationDescriptor', - // Flat sugar signatures reference the per-call option types. - ...(refs.hasFlatRegular ? ['RequestOptions'] : []), - // Flat throw-mode sugar return types vary with the inferred request-option type. - ...(refs.hasFlatRegular && ctx.errorMode !== 'result' ? ['EnvelopeResult'] : []), // `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'] : []), - ...(refs.hasFlatSse ? ['SseOptions'] : []), - // The apiKey sugar closures take a `TokenProvider`. - ...(refs.hasApiKey ? ['TokenProvider'] : []), ].sort(); const names = [...values, ...types.map((t) => `type ${t}`)].join(', '); return `import { ${names} } from '${PACKAGE_SPECIFIER}';`; } -/** One operation's `*` aliases — the same emitters and suppression rules as inline mode. */ -function aliasStatements(op: OperationModel, ctx: EmitContext): ts.Statement[] { - const { pathParams } = operationSignature(op); - const ordered = pathParams.map((p) => p.param); - const identMap = new Map(pathParams.map((p) => [p.param.name, p.ident])); - if (isSseOp(op)) return sseAliases(op, ordered, identMap, ctx, 'wire'); - const { responseType } = computeResponse(op.successResponses, ctx.dateType); - const errorMembers = - ctx.errorMode === 'result' ? errorTypeNodes(op.errorResponses, ctx.dateType) : []; - const errorAlias = errorMembers.length > 0 ? `${pascalCase(op.name)}Error` : ''; - return renderOperationAliases( - op, - responseType, - ordered, - identMap, - errorAlias, - errorMembers, - ctx, - true, - 'wire' - ); -} - /** The (optional) baked setup + the default `client` instance. */ function clientSection(options: EmitOptions, ctx: EmitContext, model: ApiModel): string { const serverUrl = options.serverUrl ?? model.serverUrl; @@ -255,6 +180,9 @@ function clientSection(options: EmitOptions, ctx: EmitContext, model: ApiModel): // relative URL, which Node's fetch rejects. ...(serverUrl !== undefined ? [`serverUrl: ${codeString(serverUrl)}`] : []), ...(ctx.errorMode === 'result' ? ['errorMode: "result"'] : []), + // The runtime converts a merged call to the namespaced shape, so it has to know + // which style this module's types promise. + ...(ctx.argsStyle === 'flat' ? ['argsStyle: "flat"'] : []), // Client identification for API-owner telemetry; the runtime sends it only // outside browsers, and `configure({ clientHeader: false })` disables it. 'clientHeader: "redocly-client-generator"', @@ -267,7 +195,7 @@ function clientSection(options: EmitOptions, ctx: EmitContext, model: ApiModel): ? `mergeSetup({ config: ${config} }, mergeSetup(__redoclySetup, {}))` : config; // The trailing type args narrow `ctx.operation` to the spec's literal unions. - // `OperationTag` mirrors descriptorStatements' gate: derived only when some + // `OperationTag` mirrors the descriptor block's gate: derived only when some // operation is tagged (it would otherwise be `never`); zero-ops specs have no // derived unions at all, so they keep the string defaults. const ops = allOperations(model.services); @@ -286,175 +214,18 @@ function clientSection(options: EmitOptions, ctx: EmitContext, model: ApiModel): } /** Core destructure + per-scheme auth setters + per-operation call sugar. */ -function sugarStatements( - ops: OperationModel[], - idents: Map, - ctx: EmitContext, - schemes: SecuritySchemeModel[], - apiKeySchemes: SecuritySchemeModel[] -): ts.Statement[] { - const statements = [...parseStatements('export const { configure, use } = client;')]; - // Auth sugar in `authSetterNames` order: bearer, basic, then each apiKey scheme. - // The runtime's auth members close over the instance config (no `this`), so - // direct bindings are safe. - if (schemes.some((s) => s.kind === 'bearer')) { - statements.push(...parseStatements('export const setBearer = client.auth.bearer;')); - } - if (schemes.some((s) => s.kind === 'basic')) { - statements.push(...parseStatements('export const setBasicAuth = client.auth.basic;')); - } - for (const scheme of apiKeySchemes) { - const name = apiKeySetterName(scheme.key, apiKeySchemes.length === 1); - statements.push( - ...parseStatements( - `export const ${name} = (value: TokenProvider) => client.auth.apiKey(${codeString(scheme.key)}, value);` - ) - ); - } - if (ops.length === 0) return statements; - if (ctx.argsStyle === 'grouped') { - // Grouped style: the client methods already take the grouped args shape. - const names = ops.map((op) => idents.get(op.name)!).join(', '); - statements.push(...parseStatements(`export const { ${names} } = client;`)); - return statements; - } - for (const op of ops) statements.push(flatSugarStatement(op, idents.get(op.name)!, ctx)); - return statements; -} - -/** - * One flat one-liner: today's positional signature forwarding to the grouped client - * method. Path values are keyed by the WIRE name (the runtime routes - * `args[param.name]`); a path param literally named `params`/`body`/`headers` would - * collide with the slot keys — a spec-acknowledged runtime-contract limitation. - * A paginated operation's arrow is wrapped in `Object.assign(…, { pages, items })` - * so the flat sugar preserves the method-attached iterators. - * Throw-mode (non-SSE) arrows are generic over `init` so `{ envelope: true }` narrows - * the return type to `Envelope<…>` (plain `RequestOptions` would collapse the overload). - */ -function flatSugarStatement(op: OperationModel, ident: string, ctx: EmitContext): ts.Statement { - const { pathParams } = operationSignature(op); - const params = renderArgList( - op, - pathParams.map((p) => p.param), - new Map(pathParams.map((p) => [p.param.name, p.ident])), - ctx - ); - const props: ts.ObjectLiteralElementLike[] = pathParams.map(({ param, ident: paramIdent }) => - param.name === paramIdent - ? factory.createShorthandPropertyAssignment(paramIdent) - : factory.createPropertyAssignment( - isIdentifier(param.name) ? param.name : factory.createStringLiteral(param.name), - factory.createIdentifier(paramIdent) - ) - ); - if (op.queryParams.length > 0) props.push(factory.createShorthandPropertyAssignment('params')); - if (op.requestBody) props.push(factory.createShorthandPropertyAssignment('body')); - if (op.headerParams.length > 0) { - props.push(factory.createShorthandPropertyAssignment('headers')); - } - if (op.cookieParams.length > 0) { - props.push(factory.createShorthandPropertyAssignment('cookies')); - } - const call = factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('client'), ident), - undefined, - [factory.createObjectLiteralExpression(props, false), factory.createIdentifier('init')] - ); - const fn = - ctx.errorMode !== 'result' && !isSseOp(op) - ? envelopeAwareFlatArrow(op, params, call, ctx) - : arrow(params, call); - if (!ctx.pagination?.has(op.name)) return exportConstStatement(ident, fn); - const methodMember = (name: string) => - factory.createPropertyAssignment( - name, - factory.createPropertyAccessExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('client'), ident), - name - ) - ); - return exportConstStatement( - ident, - factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('Object'), 'assign'), - undefined, - [ - fn, - factory.createObjectLiteralExpression( - [methodMember('pages'), methodMember('items')], - false - ), - ] - ) - ); -} - -/** - * `(…, init?: I) => - * Promise>` - */ -function envelopeAwareFlatArrow( - op: OperationModel, - params: ts.ParameterDeclaration[], - call: ts.Expression, - ctx: EmitContext -): ts.ArrowFunction { - // `renderArgList` always appends `init` last — retype it as optional generic `I` - // (no default: `init?: I = {}` is invalid, and `init: I = {}` fails under strict - // generic checks). Cast the call's Promise so the conditional return type sticks. - const initTyped = [ - ...params.slice(0, -1), - factory.createParameterDeclaration( - undefined, - undefined, - 'init', - factory.createToken(ts.SyntaxKind.QuestionToken), - factory.createTypeReferenceNode('I') - ), - ]; - const resultType = flatResultType(op, ctx); - const headersType = flatHeadersType(op, ctx); - const returnType = factory.createTypeReferenceNode('Promise', [ - factory.createTypeReferenceNode('EnvelopeResult', [ - resultType, - headersType, - factory.createTypeReferenceNode('I'), - ]), - ]); - const typeParam = factory.createTypeParameterDeclaration( - undefined, - 'I', - factory.createUnionTypeNode([ - factory.createTypeReferenceNode('RequestOptions'), - factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword), - ]), - factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword) - ); - const castCall = factory.createAsExpression(call, returnType); - return typedArrow([typeParam], initTyped, returnType, castCall); -} - -function flatResultType(op: OperationModel, ctx: EmitContext): ts.TypeNode { - const { responseType } = computeResponse(op.successResponses, ctx.dateType); - const resultName = `${pascalCase(op.name)}Result`; - return ctx.schemaNames.has(resultName) - ? responseType - : factory.createTypeReferenceNode(resultName); -} - -function flatHeadersType(op: OperationModel, ctx: EmitContext): ts.TypeNode { - const headers = op.successResponseHeaders; - if (!headers || headers.length === 0) { - return factory.createTypeReferenceNode('Record', [ - factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - factory.createKeywordTypeNode(ts.SyntaxKind.NeverKeyword), - ]); - } - const alias = `${pascalCase(op.name)}ResponseHeaders`; - return ctx.schemaNames.has(alias) - ? responseHeadersTypeLiteral(headers, ctx.schemas) - : factory.createTypeReferenceNode(alias); +function sugarSection(ops: OperationModel[], idents: Map): string { + // Credentials go through `configure({ auth })` or `client.auth.*` — one way per act. + // Per-scheme setters used to be exported here too, which gave the same act three + // spellings and a name per scheme that operation names then had to avoid. + const lines = ['export const { configure, use } = client;']; + if (ops.length === 0) return lines.join('\n'); + // Bindings, never wrappers: `updateOrder` IS `client.updateOrder`, so importing the name + // and reaching through the instance cannot disagree about the arguments. `argsStyle` + // shapes the method itself, which is why one binding serves both styles. + const names = ops.map((op) => idents.get(op.name)!).join(', '); + lines.push(`export const { ${names} } = client;`); + return lines.join('\n'); } /** Public type surface re-exported for single-import DX (plus the `ApiError` class). */ diff --git a/packages/client-generator/src/emitters/descriptor.ts b/packages/client-generator/src/emitters/descriptor.ts index b29e9ca2e2..85208cb4f0 100644 --- a/packages/client-generator/src/emitters/descriptor.ts +++ b/packages/client-generator/src/emitters/descriptor.ts @@ -1,7 +1,7 @@ // Package-mode descriptor emission: the identifier plan for a generated module that // shares scope with the `@redocly/client-generator` wiring, plus the `OPERATIONS` // descriptor map (`satisfies Record` — the semver skew -// guard against the runtime contract in src/runtime/types.ts). +// guard against the runtime contract in src/runtime/types.ts). Text templates. import { allOperations, @@ -11,30 +11,26 @@ import { type SecuritySchemeModel, } from '../intermediate-representation/model.js'; import type { SecuritySpec } from '../runtime/types.js'; -import { authSetterNames } from './auth.js'; import { uniqueIdent } from './identifier.js'; -import { variablesTypeLiteral } from './operation-aliases.js'; -import { operationSignature } from './operation-signature.js'; -import { computeResponse, errorTypeNodes, isTypedMultipart } from './operation-types.js'; -import type { EmitContext } from './operations.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 { responseHeadersTypeLiteral, responseHeaderSpecs } from './response-headers.js'; -import { isSseOp, sseDataKind, sseEventType } from './sse.js'; -import { pascalCase } from './support.js'; -import { jsdoc, literalExpression, parseStatements, ts } from './ts.js'; -import { type DateType, schemaToTypeNode } from './types.js'; - -const { factory } = ts; +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'; /** * Operation-name → emitted-identifier plan. The full reserved set (wiring + imported - * bindings + auth sugar, computed from the model FIRST) is seeded before any operation + * bindings, computed from the model FIRST) is seeded before any operation * is sanitized, so collisions rename the operation (`configure` → `configure_2`) * deterministically regardless of document order. */ export function packageIdents(model: ApiModel): Map { - const used = new Set([...WIRING_NAMES, ...authSetterNames(model.securitySchemes)]); + const used = new Set(WIRING_NAMES); const idents = new Map(); for (const op of allOperations(model.services)) idents.set(op.name, uniqueIdent(op.name, used)); return idents; @@ -46,7 +42,8 @@ function descriptorValue( schemes: SecuritySchemeModel[], dateType: DateType, pagination?: ModelPagination, - schemas: readonly NamedSchemaModel[] = [] + schemas: readonly NamedSchemaModel[] = [], + argsStyle: ArgsStyle = 'grouped' ) { const params = [...op.pathParams, ...op.queryParams, ...op.headerParams, ...op.cookieParams].map( (p) => ({ @@ -73,7 +70,7 @@ function descriptorValue( .map((alternative) => alternative.flatMap(toSpecs)) .filter((alternative) => alternative.length > 0); const sse = isSseOp(op); - const responseKind = sse ? 'sse' : computeResponse(op.successResponses, dateType).responseKind; + const responseKind = sse ? 'sse' : responseText(op.successResponses, dateType).kind; const responseHeaders = responseHeaderSpecs(op.successResponseHeaders, schemas); return { // The spec's operationId, NOT the (possibly renamed) map key: `id` drives middleware @@ -96,210 +93,56 @@ function descriptorValue( ...(responseKind !== 'json' ? { responseKind } : {}), ...(sse ? { sseDataKind: sseDataKind(op) } : {}), ...(security.length > 0 ? { security } : {}), + ...(responseHeaders === undefined ? {} : { responseHeaders }), // The resolved spec is already normalized with stable key order (see pagination.ts). ...(pagination?.has(op.name) ? { pagination: pagination.get(op.name)!.spec } : {}), - ...(responseHeaders === undefined ? {} : { responseHeaders }), + // A merged call cannot carry one name for two layers, so an operation whose names + // collide keeps the namespaced shape — its `Variables` says so, and the runtime + // has to agree or the typed call would be rejected. + ...(argsStyle === 'flat' && 'collisions' in flatInputShape(op, schemas) + ? { argsStyle: 'grouped' } + : {}), }; } -/** `export const OPERATIONS = {…} as const satisfies Record;` + unions. */ -export function descriptorStatements( +/** `export const OPERATIONS = {…} as const satisfies …` + the derived unions. */ +export function renderDescriptors( model: ApiModel, idents: Map, dateType: DateType, - pagination?: ModelPagination -): ts.Statement[] { - const ops = allOperations(model.services); - if (ops.length === 0) return []; - const entries = ops.map((op) => - factory.createPropertyAssignment( - idents.get(op.name)!, - literalExpression( - descriptorValue(op, model.securitySchemes, dateType, pagination, model.schemas) - ) - ) - ); - const operations = jsdoc( - factory.createVariableStatement( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - factory.createVariableDeclarationList( - [ - factory.createVariableDeclaration( - 'OPERATIONS', - undefined, - undefined, - factory.createSatisfiesExpression( - factory.createAsExpression( - factory.createObjectLiteralExpression(entries, true), - factory.createTypeReferenceNode('const') - ), - factory.createTypeReferenceNode('Record', [ - factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - factory.createTypeReferenceNode('OperationDescriptor'), - ]) - ) - ), - ], - ts.NodeFlags.Const - ) - ), - 'The wire-shape descriptor for every operation, keyed by operationId — the data the\n' + - 'runtime routes requests by. Also minification-safe static metadata (method, path,\n' + - 'tags) for cache keys, tracing span names, and request logging.' - ); - // `tags` is present only on tagged entries, so `OperationTag` is derived via `Extract` - // (a plain `["tags"]` index would not compile against the untagged entries), and is - // omitted entirely when no operation has a tag (it would be `never`). - const hasTags = ops.some((op) => op.tags.length > 0); - // `OperationId` is the union of descriptor `id` LITERALS — the spec operationIds the - // runtime exposes as `ctx.operation.id` — not the (possibly rename-sanitized) keys. - const derived = parseStatements( - 'export type OperationId = (typeof OPERATIONS)[keyof typeof OPERATIONS]["id"];\n' + - 'export type OperationPath = (typeof OPERATIONS)[keyof typeof OPERATIONS]["path"];' + - (hasTags - ? '\nexport type OperationTag = Extract<(typeof OPERATIONS)[keyof typeof OPERATIONS], { tags: readonly string[] }>["tags"][number];' - : '') - ); - return [operations, ...derived]; -} - -/** - * `export type Ops = { : { args: …; result: …; kind?: "sse" } }` — the type map - * `createClient` consumes. A type alias (not an interface) on purpose: aliases get - * an implicit index signature, so `Ops` satisfies the runtime's `OpsShape` constraint; - * an interface would need an explicit `[key: string]` member. - */ -export function opsInterfaceStatements( - model: ApiModel, - idents: Map, - ctx: EmitContext -): ts.Statement[] { + pagination?: ModelPagination, + argsStyle: ArgsStyle = 'grouped' +): string { const ops = allOperations(model.services); - if (ops.length === 0) return []; - return [ - jsdoc( - factory.createTypeAliasDeclaration( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - 'Ops', + if (ops.length === 0) return ''; + const entryLines = ops.map((op, index) => { + const value = codeLiteral( + descriptorValue(op, model.securitySchemes, dateType, pagination, model.schemas, argsStyle) + ); + return ` ${idents.get(op.name)!}: ${value}${index === ops.length - 1 ? '' : ','}`; + }); + const blocks = [ + [ + ...tsJsdoc( + 'The wire-shape descriptor for every operation, keyed by operationId — the data the\n' + + 'runtime routes requests by. Also minification-safe static metadata (method, path,\n' + + 'tags) for cache keys, tracing span names, and request logging.', undefined, - factory.createTypeLiteralNode(ops.map((op) => opsMember(op, idents.get(op.name)!, ctx))) + '' ), - "Per-operation `args`/`result` shapes (plus `kind: 'sse'` for event streams) — the\n" + - 'type-level companion of `OPERATIONS` that gives `createClient` its typed methods.' - ), - ]; -} - -/** One `: { args; result; kind? }` member of the `Ops` type. */ -function opsMember(op: OperationModel, ident: string, ctx: EmitContext): ts.PropertySignature { - const { pathParams } = operationSignature(op); - // Path params are keyed by WIRE name — the runtime routes `args[param.name]`. - const args = variablesTypeLiteral( - op, - pascalCase(op.name), - pathParams.map((p) => p.param), - new Map(pathParams.map((p) => [p.param.name, p.ident])), - ctx, - 'wire' - ); - const members = [ - factory.createPropertySignature(undefined, 'args', undefined, args), - factory.createPropertySignature(undefined, 'result', undefined, resultType(op, ctx)), + 'export const OPERATIONS = {', + ...entryLines, + '} as const satisfies Record;', + ].join('\n'), + 'export type OperationId = (typeof OPERATIONS)[keyof typeof OPERATIONS]["id"];', + 'export type OperationPath = (typeof OPERATIONS)[keyof typeof OPERATIONS]["path"];', ]; - if (ctx.errorMode === 'result' && !isSseOp(op)) { - members.push( - factory.createPropertySignature( - undefined, - 'mode', - undefined, - factory.createLiteralTypeNode(factory.createStringLiteral('result')) - ) + if (ops.some((op) => op.tags.length > 0)) { + blocks.push( + 'export type OperationTag = Extract<(typeof OPERATIONS)[keyof typeof OPERATIONS], {\n' + + ' tags: readonly string[];\n' + + '}>["tags"][number];' ); } - // Declared success-response headers type the throw-mode `{ envelope: true }` bag. - const responseHeaders = op.successResponseHeaders; - if (responseHeaders && responseHeaders.length > 0) { - members.push( - factory.createPropertySignature( - undefined, - 'headers', - undefined, - responseHeadersTypeLiteral(responseHeaders, ctx.schemas) - ) - ); - } - // Paginated operations declare the page's element type — it drives the runtime's - // `.pages()`/`.items()` members on the method (`Client` keys off `item`). - const paginated = ctx.pagination?.get(op.name); - if (paginated) { - members.push( - factory.createPropertySignature( - undefined, - 'item', - undefined, - schemaToTypeNode(paginated.itemSchema, ctx.dateType) - ) - ); - // Result mode wraps `result` in the envelope, but iteration unwraps it — `page` - // carries the RAW page type `.pages()` yields. Throw mode emits no `page` member - // (`Client`'s pages-generator falls back to `result`, already the raw page). - if (ctx.errorMode === 'result') { - members.push( - factory.createPropertySignature(undefined, 'page', undefined, rawResultRef(op, ctx)) - ); - } - } - if (isSseOp(op)) { - members.push( - factory.createPropertySignature( - undefined, - 'kind', - undefined, - factory.createLiteralTypeNode(factory.createStringLiteral('sse')) - ) - ); - } - return factory.createPropertySignature( - undefined, - ident, - undefined, - factory.createTypeLiteralNode(members) - ); -} - -/** - * The raw success-response reference — the same suppression rule as - * renderOperationParts: the emitted `Result` alias, or the inline response type - * when that name collides with a schema. - */ -function rawResultRef(op: OperationModel, ctx: EmitContext): ts.TypeNode { - const { responseType } = computeResponse(op.successResponses, ctx.dateType); - const resultName = `${pascalCase(op.name)}Result`; - return ctx.schemaNames.has(resultName) - ? responseType - : factory.createTypeReferenceNode(resultName); -} - -/** The `result` slot: SSE event payload, or the response type — `Result`-wrapped in result mode. */ -function resultType(op: OperationModel, ctx: EmitContext): ts.TypeNode { - if (isSseOp(op)) return sseEventType(op, ctx.dateType); - const resultRef = rawResultRef(op, ctx); - if (ctx.errorMode !== 'result') return resultRef; - return factory.createTypeReferenceNode('Result', [resultRef, errorTypeArg(op, ctx)]); -} - -/** - * The `Result<…, E>` error argument — the same composition `renderOperationParts` uses for - * `__requestResult`: `unknown` when the operation declares no error responses, the - * emitted `Error` alias otherwise, or the inline (union of) error type(s) when that - * alias name collides with a schema and is suppressed. - */ -function errorTypeArg(op: OperationModel, ctx: EmitContext): ts.TypeNode { - const errorMembers = errorTypeNodes(op.errorResponses, ctx.dateType); - if (errorMembers.length === 0) { - return factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword); - } - const errorAlias = `${pascalCase(op.name)}Error`; - if (!ctx.schemaNames.has(errorAlias)) return factory.createTypeReferenceNode(errorAlias); - return errorMembers.length === 1 ? errorMembers[0] : factory.createUnionTypeNode(errorMembers); + return blocks.join('\n\n'); } diff --git a/packages/client-generator/src/emitters/emit-options.ts b/packages/client-generator/src/emitters/emit-options.ts index e425ae1d20..de87942898 100644 --- a/packages/client-generator/src/emitters/emit-options.ts +++ b/packages/client-generator/src/emitters/emit-options.ts @@ -1,8 +1,8 @@ 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 { escapeJsDoc } from './ts.js'; import type { DateType } from './types.js'; // The public option vocabulary is re-exported from this module, so generators @@ -57,12 +57,26 @@ export type EmitOptions = { * 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-redocly-pagination` extension. Verified + * 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; }; /** diff --git a/packages/client-generator/src/emitters/faker.ts b/packages/client-generator/src/emitters/faker.ts index a19909c42e..0145663807 100644 --- a/packages/client-generator/src/emitters/faker.ts +++ b/packages/client-generator/src/emitters/faker.ts @@ -1,13 +1,13 @@ -// Builds the body expression for a faker-mode mock factory: a tree of +// 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 -// recursion + same visited-set cycle guard), but returns a `ts.Expression` of -// 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 instead of recursing forever at runtime — exactly like the -// static path. The factory signatures are identical to the static mode's, so a -// consumer can flip `mockData` without touching call sites; `@faker-js/faker` -// becomes their dev-dep while the real client stays dependency-free. +// 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 +// instead of recursing forever at runtime — exactly like the static path. The +// factory signatures are identical to the static mode's, so a consumer can flip +// `mockData` without touching call sites; `@faker-js/faker` becomes their +// dev-dep while the real client stays dependency-free. import type { NamedSchemaModel, @@ -15,14 +15,12 @@ import type { SchemaMetadata, SchemaModel, } from '../intermediate-representation/model.js'; -import { safeIdent } from './identifier.js'; +import { expr, isObjectValue, type MockEntry, type MockValue, objectValue } from './mock-value.js'; import { splitIntersection } from './sample.js'; -import { constArray, literalExpression, ts } from './ts.js'; +import { codeLiteral } from './ts-literal.js'; import type { DateType } from './types.js'; -const { factory } = ts; - -/** The faker-call expression for an IR schema. Refs resolve against `schemas`; +/** 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 * the sdk's `--date-type`: under `'Date'`, date fields stay `faker.date.recent()` * (a `Date`); otherwise they are stringified to match the `string`-typed sdk. */ @@ -30,12 +28,12 @@ export function fakerExpression( schema: SchemaModel, schemas: NamedSchemaModel[], opts: { dateType?: DateType } = {} -): ts.Expression { +): MockValue { const byName = new Map(schemas.map((s) => [s.name, s.schema])); - const expr = walk(schema, byName, new Set(), opts.dateType ?? 'string'); + const value = walk(schema, byName, new Set(), opts.dateType ?? 'string'); // A `CYCLE` that reaches the root has no container to absorb it (e.g. a // self-referential union); fall back to null. - return expr === CYCLE ? factory.createNull() : expr; + return value === CYCLE ? expr('null') : value; } /** @@ -47,7 +45,7 @@ export function fakerExpression( */ const CYCLE = Symbol('cycle'); -type WalkResult = ts.Expression | typeof CYCLE; +type WalkResult = MockValue | typeof CYCLE; function walk( schema: SchemaModel, @@ -57,39 +55,39 @@ function walk( ): WalkResult { switch (schema.kind) { case 'scalar': - return scalarExpr(schema.scalar, schema.metadata, dateType); + return expr(scalarExpr(schema.scalar, schema.metadata, dateType)); case 'array': { // A cyclic item type collapses the array to `[]` — itself a valid `T[]`. const item = walk(schema.items, byName, visiting, dateType); - return item === CYCLE ? factory.createArrayLiteralExpression([], false) : multiple(item); + return item === CYCLE ? expr('[]') : multiple(item); } case 'object': - return objectExpr( - schema.properties.flatMap((p): Array<[string, ts.Expression]> => { + return objectValue( + schema.properties.flatMap((p): MockEntry[] => { const value = walk(p.schema, byName, visiting, dateType); // A cyclic optional property is omitted; a cyclic required property is // uninhabitable, so null is the only stand-in. - if (value === CYCLE) return p.required ? [[p.name, factory.createNull()]] : []; - return [[p.name, value]]; + if (value === CYCLE) return p.required ? [{ key: p.name, value: expr('null') }] : []; + return [{ key: p.name, value }]; }) ); case 'record': { const value = walk(schema.value, byName, visiting, dateType); - return value === CYCLE - ? factory.createObjectLiteralExpression([], false) - : objectExpr([['key', value]]); + return value === CYCLE ? expr('{}') : objectValue([{ key: 'key', value }]); } case 'enum': - return call('faker.helpers.arrayElement', [constArray(schema.values.map(literalExpression))]); + return expr( + `faker.helpers.arrayElement([${schema.values.map((value) => codeLiteral(value)).join(', ')}] as const)` + ); case 'literal': - return literalExpression(schema.value); + return expr(codeLiteral(schema.value)); case 'union': { // First non-cyclic member; if every member cycles, propagate `CYCLE`. for (const member of schema.members) { const value = walk(member, byName, visiting, dateType); if (value !== CYCLE) return value; } - return schema.members.length > 0 ? CYCLE : factory.createNull(); + return schema.members.length > 0 ? CYCLE : expr('null'); } case 'intersection': { // Mirror the static sampler: object members merge into one synthetic object whose @@ -99,26 +97,25 @@ function walk( const { merged, rest } = splitIntersection(schema.members, byName); const parts = rest .map((member) => walk(member, byName, visiting, dateType)) - .filter((part): part is ts.Expression => part !== CYCLE); + .filter((part): part is MockValue => part !== CYCLE); if (merged) { const value = walk(merged, byName, visiting, dateType); - const own = - value !== CYCLE && ts.isObjectLiteralExpression(value) ? assignments(value) : []; - const folded = parts.filter(ts.isObjectLiteralExpression).flatMap(assignments); - return factory.createObjectLiteralExpression([...own, ...folded], true); + const own = value !== CYCLE && isObjectValue(value) ? value.entries : []; + const folded = parts.filter(isObjectValue).flatMap((part) => part.entries); + return objectValue([...own, ...folded]); } - const objects = parts.filter(ts.isObjectLiteralExpression); + const objects = parts.filter(isObjectValue); if (objects.length > 0) { - return factory.createObjectLiteralExpression(objects.flatMap(assignments), true); + return objectValue(objects.flatMap((part) => part.entries)); } - return parts[0] ?? factory.createObjectLiteralExpression([], true); + return parts[0] ?? objectValue([]); } case 'omit': return omitExpr(schema.base, schema.keys, byName, visiting, dateType); case 'ref': { if (visiting.has(schema.name)) return CYCLE; const target = byName.get(schema.name); - if (!target) return factory.createNull(); + if (!target) return expr('null'); visiting.add(schema.name); const result = walk(target, byName, visiting, dateType); visiting.delete(schema.name); @@ -126,7 +123,7 @@ function walk( } case 'null': case 'unknown': - return factory.createNull(); + return expr('null'); } } @@ -137,75 +134,63 @@ function scalarExpr( scalar: ScalarKind, meta: SchemaMetadata | undefined, dateType: DateType -): ts.Expression { - if (meta?.format === 'binary') return newBlob(); - if (scalar === 'boolean') return call('faker.datatype.boolean', []); - if (scalar === 'integer') return call('faker.number.int', boundsArg(meta)); - if (scalar === 'number') return call('faker.number.float', boundsArg(meta)); +): string { + if (meta?.format === 'binary') return 'new Blob([])'; + if (scalar === 'boolean') return 'faker.datatype.boolean()'; + if (scalar === 'integer') return `faker.number.int(${boundsArg(meta)})`; + if (scalar === 'number') return `faker.number.float(${boundsArg(meta)})`; switch (meta?.format) { case 'email': - return call('faker.internet.email', []); + return 'faker.internet.email()'; case 'uuid': - return call('faker.string.uuid', []); + return 'faker.string.uuid()'; case 'uri': case 'url': - return call('faker.internet.url', []); + return 'faker.internet.url()'; case 'hostname': - return call('faker.internet.domainName', []); + return 'faker.internet.domainName()'; case 'ipv4': - return call('faker.internet.ipv4', []); + return 'faker.internet.ipv4()'; case 'date-time': return dateExpr(dateType, false); case 'date': return dateExpr(dateType, true); default: - return call('faker.lorem.word', []); + return 'faker.lorem.word()'; } } /** `faker.date.recent()` (under `dateType: 'Date'`); else its ISO string, sliced to * `YYYY-MM-DD` for a `date` so the wire shape matches the `string`-typed field. */ -function dateExpr(dateType: DateType, dateOnly: boolean): ts.Expression { - const recent = call('faker.date.recent', []); - if (dateType === 'Date') return recent; - const iso = call(member(recent, 'toISOString'), []); - if (!dateOnly) return iso; - return call(member(iso, 'slice'), [ - factory.createNumericLiteral(0), - factory.createNumericLiteral(10), - ]); +function dateExpr(dateType: DateType, dateOnly: boolean): string { + if (dateType === 'Date') return 'faker.date.recent()'; + const iso = 'faker.date.recent().toISOString()'; + return dateOnly ? `${iso}.slice(0, 10)` : iso; } -/** `{ min, max }` arg list for a bounded numeric, or no args when neither bound is set. */ -function boundsArg(meta: SchemaMetadata | undefined): ts.Expression[] { - const props: ts.PropertyAssignment[] = []; - if (meta?.minimum !== undefined) { - props.push(factory.createPropertyAssignment('min', literalExpression(meta.minimum))); - } - if (meta?.maximum !== undefined) { - props.push(factory.createPropertyAssignment('max', literalExpression(meta.maximum))); - } - return props.length > 0 ? [factory.createObjectLiteralExpression(props, false)] : []; +/** `{ min, max }` arg for a bounded numeric, or empty when neither bound is set. */ +function boundsArg(meta: SchemaMetadata | undefined): string { + const props = [ + ...(meta?.minimum !== undefined ? [`min: ${meta.minimum}`] : []), + ...(meta?.maximum !== undefined ? [`max: ${meta.maximum}`] : []), + ]; + return props.length > 0 ? `{ ${props.join(', ')} }` : ''; } -/** `faker.helpers.multiple(() => , { count: 1 })` — one element keeps output small. */ -function multiple(item: ts.Expression): ts.Expression { - const fn = factory.createArrowFunction( - undefined, - undefined, - [], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - item - ); - const count = factory.createObjectLiteralExpression( - [factory.createPropertyAssignment('count', factory.createNumericLiteral(1))], - false - ); - return call('faker.helpers.multiple', [fn, count]); +/** `faker.helpers.multiple(() => , { count: 1 })` — one element keeps output small. + * An object-literal arrow body must be parenthesized (`() => ({ … })`), or the braces + * parse as a block. */ +function multiple(item: MockValue): MockValue { + const object = isObjectValue(item); + return { + kind: 'wrap', + before: `faker.helpers.multiple(() => ${object ? '(' : ''}`, + value: item, + after: `${object ? ')' : ''}, { count: 1 })`, + }; } -/** An `omit`: the base named schema's faker expr minus the dropped keys. Resolves the +/** An `omit`: the base named schema's faker value minus the dropped keys. Resolves the * base via the schema set (cycle-guarded); a non-object base passes through unchanged. */ function omitExpr( base: string, @@ -215,61 +200,10 @@ function omitExpr( dateType: DateType ): WalkResult { const target = byName.get(base); - if (!target) return factory.createNull(); - const expr = walk(target, byName, visiting, dateType); + if (!target) return expr('null'); + const value = walk(target, byName, visiting, dateType); // A cyclic or non-object base passes through unchanged (a container/root absorbs `CYCLE`). - if (expr === CYCLE || !ts.isObjectLiteralExpression(expr)) return expr; - const drop = new Set(keys.map(safeIdent)); - return factory.createObjectLiteralExpression( - assignments(expr).filter((a) => !drop.has(propKey(a))), - true - ); -} - -/** An object literal from `[key, expr]` entries; keys are quoted when not bare identifiers. */ -function objectExpr(entries: Array<[string, ts.Expression]>): ts.Expression { - return factory.createObjectLiteralExpression( - entries.map(([key, value]) => { - const safe = safeIdent(key); - const name = safe === key ? factory.createIdentifier(key) : factory.createStringLiteral(key); - return factory.createPropertyAssignment(name, value); - }), - true - ); -} - -/** The property assignments of an object literal (the spread/intersection merge unit). */ -function assignments(object: ts.ObjectLiteralExpression): ts.PropertyAssignment[] { - return object.properties.filter((p): p is ts.PropertyAssignment => ts.isPropertyAssignment(p)); -} - -/** The printed key text of a property assignment (matching `safeIdent`'s quoting). */ -function propKey(a: ts.PropertyAssignment): string { - return ts.isStringLiteral(a.name) ? safeIdent(a.name.text) : (a.name as ts.Identifier).text; -} - -/** `new Blob([])` — the type-correct stand-in for a `format: binary` field. */ -function newBlob(): ts.Expression { - return factory.createNewExpression(factory.createIdentifier('Blob'), undefined, [ - factory.createArrayLiteralExpression([], false), - ]); -} - -/** A call expression from a dotted callee name (`faker.number.int`) or a built node. */ -function call(callee: string | ts.Expression, args: ts.Expression[]): ts.CallExpression { - const target = typeof callee === 'string' ? dotted(callee) : callee; - return factory.createCallExpression(target, undefined, args); -} - -/** Turn `a.b.c` into nested property access on an identifier. */ -function dotted(path: string): ts.Expression { - const [head, ...rest] = path.split('.'); - return rest.reduce( - (acc, name) => member(acc, name), - factory.createIdentifier(head) - ); -} - -function member(target: ts.Expression, name: string): ts.PropertyAccessExpression { - return factory.createPropertyAccessExpression(target, name); + if (value === CYCLE || !isObjectValue(value)) return value; + const drop = new Set(keys); + return objectValue(value.entries.filter((entry) => 'spread' in entry || !drop.has(entry.key))); } diff --git a/packages/client-generator/src/emitters/go-runtime-sources.ts b/packages/client-generator/src/emitters/go-runtime-sources.ts new file mode 100644 index 0000000000..fb634cfdae --- /dev/null +++ b/packages/client-generator/src/emitters/go-runtime-sources.ts @@ -0,0 +1,3 @@ +// GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. +export const GO_RUNTIME_SOURCE = + '// Package client — the embedded runtime for generated Go SDKs. Hand-authored\n// once and stitched into every generated client (see\n// scripts/generate-runtime-sources.mjs), semantically in lockstep with the\n// TypeScript runtime: auth OR-alternatives, a retry loop with Retry-After and\n// full-jitter backoff, per-attempt timeouts, idempotency keys, and middleware\n// hooks. Standard library only — a generated Go SDK has zero dependencies.\npackage client\n\nimport (\n\t"bytes"\n\t"context"\n\t"encoding/base64"\n\t"encoding/json"\n\t"errors"\n\t"fmt"\n\t"io"\n\t"math/rand"\n\t"mime/multipart"\n\t"net/http"\n\t"net/url"\n\t"strconv"\n\t"strings"\n\t"time"\n)\n\n// APIError is returned for a non-2xx response, carrying the decoded error body.\ntype APIError struct {\n\tURL string\n\tStatus int\n\tStatusText string\n\tBody any\n}\n\nfunc (e *APIError) Error() string {\n\treturn fmt.Sprintf("request failed with status %d", e.Status)\n}\n\n// TimeoutError is returned when a request attempt exceeds the configured\n// timeout — carrying the context a log line needs.\ntype TimeoutError struct {\n\tOperationID string\n\tTimeout time.Duration\n\tAttempt int\n}\n\nfunc (e *TimeoutError) Error() string {\n\treturn fmt.Sprintf("request %q timed out after %s (attempt %d)", e.OperationID, e.Timeout, e.Attempt)\n}\n\n// SecuritySpec mirrors the descriptor table\'s security entries.\ntype SecuritySpec struct {\n\tScheme string\n\tKind string // "bearer" | "basic" | "apiKey"\n\tName string // header/query/cookie name for apiKey\n\tIn string // "header" | "query" | "cookie"\n}\n\n// Auth holds the client credentials; zero value = anonymous.\ntype Auth struct {\n\tBearer func() string\n\tBasic *BasicAuth\n\tAPIKey map[string]func() string\n}\n\ntype BasicAuth struct {\n\tUsername string\n\tPassword string\n}\n\n// RetryConfig mirrors the TypeScript runtime\'s retry policy knobs.\ntype RetryConfig struct {\n\tRetries int\n\tRetryDelay time.Duration // base; default 1s\n\tRetryStrategy string // "" (exponential) | "fixed"\n\tNoJitter bool\n\t// RetryOn fully replaces the default predicate when set.\n\tRetryOn func(attempt int, resp *http.Response, err error) bool\n}\n\n// Middleware hooks run around every request (OnRequest before serialization order\n// is N/A in Go — bodies are values; OnResponse runs in reverse registration order).\ntype Middleware struct {\n\tOnRequest func(req *http.Request)\n\tOnResponse func(resp *http.Response)\n}\n\n// Date is an RFC 3339 full-date — a calendar date with no time component. Fields\n// typed `date` under `dateType: Date` use it because encoding/json speaks only\n// RFC 3339 date-time for time.Time, which a bare "2006-01-02" fails to satisfy.\ntype Date struct {\n\ttime.Time\n}\n\nconst dateLayout = "2006-01-02"\n\n// UnmarshalJSON parses a "2006-01-02" string; an empty string leaves the zero value.\nfunc (d *Date) UnmarshalJSON(data []byte) error {\n\tvar raw string\n\tif err := json.Unmarshal(data, &raw); err != nil {\n\t\treturn err\n\t}\n\tif raw == "" {\n\t\treturn nil\n\t}\n\tparsed, err := time.Parse(dateLayout, raw)\n\tif err != nil {\n\t\treturn err\n\t}\n\td.Time = parsed\n\treturn nil\n}\n\n// MarshalJSON writes the date back without a time component.\nfunc (d Date) MarshalJSON() ([]byte, error) {\n\treturn json.Marshal(d.Format(dateLayout))\n}\n\n// Config is the per-client configuration shared by every operation method.\ntype Config struct {\n\tServerURL string\n\tHTTPClient *http.Client\n\tHeaders map[string]string\n\tTimeout time.Duration\n\tRetry RetryConfig\n\tMiddleware []Middleware\n\tIdempotencyKey func() string\n\tAuth Auth\n}\n\nfunc resolveToken(provider func() string) string {\n\tif provider == nil {\n\t\treturn ""\n\t}\n\treturn provider()\n}\n\nfunc schemeConfigured(spec SecuritySpec, auth Auth) bool {\n\tswitch spec.Kind {\n\tcase "apiKey":\n\t\t_, ok := auth.APIKey[spec.Scheme]\n\t\treturn ok\n\tcase "bearer":\n\t\treturn auth.Bearer != nil\n\tdefault:\n\t\treturn auth.Basic != nil\n\t}\n}\n\n// resolveAuth applies the first fully-configured OR-alternative; when none is,\n// the first alternative\'s configured schemes are still sent (the server rejects\n// the request — same behavior as the TypeScript runtime).\nfunc resolveAuth(security [][]SecuritySpec, auth Auth) (map[string]string, url.Values) {\n\theaders := map[string]string{}\n\tquery := url.Values{}\n\tif len(security) == 0 {\n\t\treturn headers, query\n\t}\n\talternative := security[0]\n\tfor _, candidate := range security {\n\t\tall := true\n\t\tfor _, spec := range candidate {\n\t\t\tif !schemeConfigured(spec, auth) {\n\t\t\t\tall = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif all {\n\t\t\talternative = candidate\n\t\t\tbreak\n\t\t}\n\t}\n\tvar cookies []string\n\tfor _, spec := range alternative {\n\t\tswitch spec.Kind {\n\t\tcase "apiKey":\n\t\t\tprovider, ok := auth.APIKey[spec.Scheme]\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalue := resolveToken(provider)\n\t\t\tswitch spec.In {\n\t\t\tcase "query":\n\t\t\t\tquery.Set(spec.Name, value)\n\t\t\tcase "cookie":\n\t\t\t\tcookies = append(cookies, spec.Name+"="+url.QueryEscape(value))\n\t\t\tdefault:\n\t\t\t\theaders[spec.Name] = value\n\t\t\t}\n\t\tcase "bearer":\n\t\t\tif auth.Bearer != nil {\n\t\t\t\theaders["Authorization"] = "Bearer " + resolveToken(auth.Bearer)\n\t\t\t}\n\t\tdefault:\n\t\t\tif auth.Basic != nil {\n\t\t\t\ttoken := base64.StdEncoding.EncodeToString([]byte(auth.Basic.Username + ":" + auth.Basic.Password))\n\t\t\t\theaders["Authorization"] = "Basic " + token\n\t\t\t}\n\t\t}\n\t}\n\tif len(cookies) > 0 {\n\t\theaders["Cookie"] = strings.Join(cookies, "; ")\n\t}\n\treturn headers, query\n}\n\n// buildURL substitutes {param} path placeholders with percent-encoded values.\nfunc buildURL(serverURL, path string, pathParams map[string]string) string {\n\tfilled := path\n\tfor name, value := range pathParams {\n\t\tfilled = strings.ReplaceAll(filled, "{"+name+"}", url.PathEscape(value))\n\t}\n\treturn strings.TrimRight(serverURL, "/") + filled\n}\n\nvar transientStatus = map[int]bool{408: true, 429: true, 500: true, 502: true, 503: true, 504: true}\n\nfunc defaultRetryOn(method string, headers map[string]string, resp *http.Response, err error) bool {\n\tsafe := false\n\tswitch strings.ToUpper(method) {\n\tcase "GET", "HEAD", "PUT", "DELETE", "OPTIONS":\n\t\tsafe = true\n\t}\n\tif _, ok := headers["Idempotency-Key"]; ok {\n\t\tsafe = true\n\t}\n\tif !safe {\n\t\treturn false\n\t}\n\tif err != nil {\n\t\treturn true\n\t}\n\treturn resp != nil && transientStatus[resp.StatusCode]\n}\n\nfunc retryDelay(retry RetryConfig, attempt int, retryAfter string) time.Duration {\n\tif retryAfter != "" {\n\t\tif seconds, err := strconv.ParseFloat(retryAfter, 64); err == nil {\n\t\t\treturn time.Duration(seconds * float64(time.Second))\n\t\t}\n\t}\n\tbase := retry.RetryDelay\n\tif base == 0 {\n\t\tbase = time.Second\n\t}\n\traw := base\n\tif retry.RetryStrategy != "fixed" {\n\t\traw = base * time.Duration(1<<(attempt-1))\n\t}\n\tif retry.NoJitter {\n\t\treturn raw\n\t}\n\treturn time.Duration(rand.Int63n(int64(raw) + 1))\n}\n\ntype requestSpec struct {\n\tOperationID string\n\tMethod string\n\tURL string\n\tHeaders map[string]string\n\tQuery url.Values\n\tBody io.Reader\n\tContentType string\n\tTimeout time.Duration\n\tRetry *RetryConfig\n\tIdempotencyKey string\n\t// bodyBytes is retained so retries can replay the body.\n\tbodyBytes []byte\n}\n\n// send is the request core: header merge, idempotency keys, the retry loop\n// (fresh timeout budget per attempt), and the middleware onion.\nfunc send(ctx context.Context, config *Config, spec requestSpec) (*http.Response, error) {\n\tretry := config.Retry\n\tif spec.Retry != nil {\n\t\tretry = *spec.Retry\n\t}\n\ttimeout := config.Timeout\n\tif spec.Timeout != 0 {\n\t\ttimeout = spec.Timeout\n\t}\n\theaders := map[string]string{}\n\tfor key, value := range config.Headers {\n\t\theaders[key] = value\n\t}\n\tfor key, value := range spec.Headers {\n\t\theaders[key] = value\n\t}\n\tmethod := strings.ToUpper(spec.Method)\n\tif (method == "POST" || method == "PATCH") && headers["Idempotency-Key"] == "" {\n\t\tif spec.IdempotencyKey != "" {\n\t\t\theaders["Idempotency-Key"] = spec.IdempotencyKey\n\t\t} else if config.IdempotencyKey != nil {\n\t\t\theaders["Idempotency-Key"] = config.IdempotencyKey()\n\t\t}\n\t}\n\thttpClient := config.HTTPClient\n\tif httpClient == nil {\n\t\thttpClient = http.DefaultClient\n\t}\n\tif spec.Body != nil {\n\t\tpayload, err := io.ReadAll(spec.Body)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tspec.bodyBytes = payload\n\t}\n\tfullURL := spec.URL\n\tif len(spec.Query) > 0 {\n\t\tseparator := "?"\n\t\tif strings.Contains(fullURL, "?") {\n\t\t\tseparator = "&"\n\t\t}\n\t\tfullURL += separator + spec.Query.Encode()\n\t}\n\tmaxAttempts := 1 + retry.Retries\n\tfor attempt := 1; ; attempt++ {\n\t\tattemptCtx := ctx\n\t\tvar cancel context.CancelFunc\n\t\tif timeout > 0 {\n\t\t\tattemptCtx, cancel = context.WithTimeout(ctx, timeout)\n\t\t}\n\t\tvar bodyReader io.Reader\n\t\tif spec.bodyBytes != nil {\n\t\t\tbodyReader = bytes.NewReader(spec.bodyBytes)\n\t\t}\n\t\treq, err := http.NewRequestWithContext(attemptCtx, method, fullURL, bodyReader)\n\t\tif err != nil {\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tfor key, value := range headers {\n\t\t\treq.Header.Set(key, value)\n\t\t}\n\t\tif spec.ContentType != "" && spec.bodyBytes != nil {\n\t\t\treq.Header.Set("Content-Type", spec.ContentType)\n\t\t}\n\t\tfor _, mw := range config.Middleware {\n\t\t\tif mw.OnRequest != nil {\n\t\t\t\tmw.OnRequest(req)\n\t\t\t}\n\t\t}\n\t\tresp, err := httpClient.Do(req)\n\t\tshouldRetry := retry.RetryOn\n\t\tretryable := false\n\t\tif shouldRetry != nil {\n\t\t\tretryable = shouldRetry(attempt, resp, err)\n\t\t} else {\n\t\t\tretryable = defaultRetryOn(method, headers, resp, err)\n\t\t}\n\t\tif err != nil {\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\ttimedOut := errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil\n\t\t\tif attempt < maxAttempts && retryable {\n\t\t\t\ttime.Sleep(retryDelay(retry, attempt, ""))\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif timedOut {\n\t\t\t\treturn nil, &TimeoutError{OperationID: spec.OperationID, Timeout: timeout, Attempt: attempt}\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tfor i := len(config.Middleware) - 1; i >= 0; i-- {\n\t\t\tif config.Middleware[i].OnResponse != nil {\n\t\t\t\tconfig.Middleware[i].OnResponse(resp)\n\t\t\t}\n\t\t}\n\t\tif resp.StatusCode >= 400 && attempt < maxAttempts && retryable {\n\t\t\tafter := resp.Header.Get("Retry-After")\n\t\t\tio.Copy(io.Discard, resp.Body)\n\t\t\tresp.Body.Close()\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\ttime.Sleep(retryDelay(retry, attempt, after))\n\t\t\tcontinue\n\t\t}\n\t\t// The response body outlives this call; tie the attempt context\'s lifetime to it.\n\t\tif cancel != nil {\n\t\t\tresp.Body = &cancelOnClose{ReadCloser: resp.Body, cancel: cancel}\n\t\t}\n\t\treturn resp, nil\n\t}\n}\n\ntype cancelOnClose struct {\n\tio.ReadCloser\n\tcancel context.CancelFunc\n}\n\nfunc (c *cancelOnClose) Close() error {\n\tc.cancel()\n\treturn c.ReadCloser.Close()\n}\n\n// decodeJSON decodes a response body into target; a nil target drains and closes.\nfunc decodeJSON(resp *http.Response, target any) error {\n\tdefer resp.Body.Close()\n\tif target == nil {\n\t\t_, err := io.Copy(io.Discard, resp.Body)\n\t\treturn err\n\t}\n\treturn json.NewDecoder(resp.Body).Decode(target)\n}\n\n// headerString returns the named response header, or nil when absent.\nfunc headerString(header http.Header, name string) *string {\n\tvalue := header.Get(name)\n\tif value == "" {\n\t\treturn nil\n\t}\n\treturn &value\n}\n\n// headerInt64 parses the named header as an integer; nil when absent or unparsable.\nfunc headerInt64(header http.Header, name string) *int64 {\n\traw := strings.TrimSpace(header.Get(name))\n\tif raw == "" {\n\t\treturn nil\n\t}\n\tvalue, err := strconv.ParseInt(raw, 10, 64)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &value\n}\n\n// headerFloat64 parses the named header as a number; nil when absent or unparsable.\nfunc headerFloat64(header http.Header, name string) *float64 {\n\traw := strings.TrimSpace(header.Get(name))\n\tif raw == "" {\n\t\treturn nil\n\t}\n\tvalue, err := strconv.ParseFloat(raw, 64)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &value\n}\n\n// headerBool parses a `true`/`false` header; nil when absent or anything else.\nfunc headerBool(header http.Header, name string) *bool {\n\traw := strings.ToLower(strings.TrimSpace(header.Get(name)))\n\tif raw != "true" && raw != "false" {\n\t\treturn nil\n\t}\n\tvalue := raw == "true"\n\treturn &value\n}\n\n// apiErrorFrom builds the structured error for a non-2xx response.\nfunc apiErrorFrom(resp *http.Response, requestURL string) error {\n\tdefer resp.Body.Close()\n\tvar body any\n\tdata, _ := io.ReadAll(resp.Body)\n\tif len(data) > 0 {\n\t\tif err := json.Unmarshal(data, &body); err != nil {\n\t\t\tbody = string(data)\n\t\t}\n\t}\n\treturn &APIError{URL: requestURL, Status: resp.StatusCode, StatusText: resp.Status, Body: body}\n}\n\n// ─── Pagination ───\n\n// PaginationSpec mirrors the descriptor table\'s pagination entries.\ntype PaginationSpec struct {\n\tStyle string\n\tParam string\n\tNextCursor string\n\tHasMore string\n\tLimitParam string\n\tItems string\n}\n\n// resolvePointer walks an RFC 6901 JSON pointer over decoded JSON; nil on any miss.\nfunc resolvePointer(data any, pointer string) any {\n\tif pointer == "" {\n\t\treturn data\n\t}\n\tif !strings.HasPrefix(pointer, "/") {\n\t\treturn nil\n\t}\n\tcurrent := data\n\tfor _, token := range strings.Split(pointer[1:], "/") {\n\t\tkey := strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~")\n\t\tswitch typed := current.(type) {\n\t\tcase map[string]any:\n\t\t\tcurrent = typed[key]\n\t\tcase []any:\n\t\t\tindex, err := strconv.Atoi(key)\n\t\t\tif err != nil || index < 0 || index >= len(typed) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tcurrent = typed[index]\n\t\tdefault:\n\t\t\treturn nil\n\t\t}\n\t\tif current == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn current\n}\n\n// reencode converts decoded JSON (maps/slices) into a typed value via a JSON round-trip.\nfunc reencode(raw any, target any) error {\n\tdata, err := json.Marshal(raw)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn json.Unmarshal(data, target)\n}\n\ntype pageCall func(params url.Values) (any, *http.Response, error)\n\n// iterPages yields raw page JSON per the pagination spec — the same stop\n// conditions and infinite-loop guards as the TypeScript runtime. The returned\n// function is a range-over-func iterator (Go 1.23+) and plainly callable before that.\nfunc iterPages(call pageCall, spec PaginationSpec, base url.Values) func(yield func(any, error) bool) {\n\treturn func(yield func(any, error) bool) {\n\t\tswitch spec.Style {\n\t\tcase "cursor":\n\t\t\tvar cursor any\n\t\t\tif values, ok := base[spec.Param]; ok && len(values) > 0 {\n\t\t\t\tcursor = values[0]\n\t\t\t}\n\t\t\tfor {\n\t\t\t\tparams := cloneValues(base)\n\t\t\t\tif cursor != nil {\n\t\t\t\t\tparams.Set(spec.Param, fmt.Sprint(cursor))\n\t\t\t\t}\n\t\t\t\tpage, _, err := call(params)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif !yield(page, nil) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif spec.HasMore != "" {\n\t\t\t\t\tif more, ok := resolvePointer(page, spec.HasMore).(bool); ok && !more {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tnext := resolvePointer(page, spec.NextCursor)\n\t\t\t\tif next == nil || next == "" {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tswitch next.(type) {\n\t\t\t\tcase string, float64:\n\t\t\t\tdefault:\n\t\t\t\t\tyield(nil, fmt.Errorf("pagination cursor at %s is not a string or number", spec.NextCursor))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif cursor != nil && fmt.Sprint(next) == fmt.Sprint(cursor) {\n\t\t\t\t\tyield(nil, errors.New("pagination did not advance: the operation returned the same cursor twice"))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tcursor = next\n\t\t\t}\n\t\tcase "link":\n\t\t\tparams := cloneValues(base)\n\t\t\tprevious := ""\n\t\t\tfor {\n\t\t\t\tpage, resp, err := call(params)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif !yield(page, nil) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\ttarget := linkNext(resp.Header.Get("Link"))\n\t\t\t\tif target == "" {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tpageURL := ""\n\t\t\t\tif resp.Request != nil && resp.Request.URL != nil {\n\t\t\t\t\tpageURL = resp.Request.URL.String()\n\t\t\t\t}\n\t\t\t\tbaseURL, err := url.Parse(pageURL)\n\t\t\t\tif err != nil || pageURL == "" {\n\t\t\t\t\tbaseURL, _ = url.Parse("http://relative.invalid")\n\t\t\t\t}\n\t\t\t\ttargetURL, err := baseURL.Parse(target)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tnext := targetURL.String()\n\t\t\t\tif next == previous || next == pageURL {\n\t\t\t\t\tyield(nil, errors.New(`pagination did not advance: the Link rel="next" target repeats`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tprevious = next\n\t\t\t\tparams = cloneValues(base)\n\t\t\t\tfor key, values := range targetURL.Query() {\n\t\t\t\t\tfor _, value := range values {\n\t\t\t\t\t\tparams.Add(key, value)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\tdefault: // offset / page\n\t\t\tposition := 0\n\t\t\tif spec.Style == "page" {\n\t\t\t\tposition = 1\n\t\t\t}\n\t\t\tif values, ok := base[spec.Param]; ok && len(values) > 0 && values[0] != "" {\n\t\t\t\tif parsed, err := strconv.Atoi(values[0]); err == nil {\n\t\t\t\t\tposition = parsed\n\t\t\t\t}\n\t\t\t}\n\t\t\tpreviousItems := ""\n\t\t\tfor {\n\t\t\t\tparams := cloneValues(base)\n\t\t\t\tparams.Set(spec.Param, strconv.Itoa(position))\n\t\t\t\tpage, _, err := call(params)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\titems, _ := resolvePointer(page, spec.Items).([]any)\n\t\t\t\tserialized := ""\n\t\t\t\tif items != nil {\n\t\t\t\t\tserialized = fmt.Sprint(items)\n\t\t\t\t\tif serialized == previousItems {\n\t\t\t\t\t\tyield(nil, errors.New("pagination did not advance: the operation returned the same page twice"))\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif !yield(page, nil) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif len(items) == 0 {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tpreviousItems = serialized\n\t\t\t\tif spec.Style == "page" {\n\t\t\t\t\tposition++\n\t\t\t\t} else {\n\t\t\t\t\tposition += len(items)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc cloneValues(values url.Values) url.Values {\n\tout := url.Values{}\n\tfor key, entries := range values {\n\t\tfor _, entry := range entries {\n\t\t\tout.Add(key, entry)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc linkNext(header string) string {\n\tif header == "" {\n\t\treturn ""\n\t}\n\tfor _, entry := range strings.Split(header, ",") {\n\t\tparts := strings.Split(entry, ";")\n\t\tif len(parts) < 2 {\n\t\t\tcontinue\n\t\t}\n\t\ttarget := strings.TrimSpace(parts[0])\n\t\tif !strings.HasPrefix(target, "<") || !strings.HasSuffix(target, ">") {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, param := range parts[1:] {\n\t\t\ttrimmed := strings.TrimSpace(param)\n\t\t\tif strings.HasPrefix(trimmed, "rel=") {\n\t\t\t\trel := strings.Trim(strings.TrimPrefix(trimmed, "rel="), `"`)\n\t\t\t\tfor _, kind := range strings.Fields(rel) {\n\t\t\t\t\tif kind == "next" {\n\t\t\t\t\t\treturn strings.Trim(target, "<>")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn ""\n}\n\n// ─── Server-Sent Events ───\n\n// ServerSentEvent is one decoded event; Data is the raw text (or parsed JSON\n// for operations that declare a JSON event stream).\ntype ServerSentEvent struct {\n\tEvent string\n\tData any\n\tID string\n\tRetry int\n}\n\nfunc parseSSEFrame(raw string, jsonData bool) (ServerSentEvent, bool, error) {\n\tevent := ServerSentEvent{Retry: -1}\n\tsawField := false\n\tvar dataLines []string\n\tnormalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\\r\\n", "\\n"), "\\r", "\\n")\n\tfor _, line := range strings.Split(normalized, "\\n") {\n\t\tif line == "" || strings.HasPrefix(line, ":") {\n\t\t\tcontinue\n\t\t}\n\t\tfield, value, _ := strings.Cut(line, ":")\n\t\tvalue = strings.TrimPrefix(value, " ")\n\t\tsawField = true\n\t\tswitch field {\n\t\tcase "event":\n\t\t\tevent.Event = value\n\t\tcase "data":\n\t\t\tdataLines = append(dataLines, value)\n\t\tcase "id":\n\t\t\tevent.ID = value\n\t\tcase "retry":\n\t\t\tif parsed, err := strconv.Atoi(value); err == nil && parsed >= 0 && value != "" {\n\t\t\t\tevent.Retry = parsed\n\t\t\t}\n\t\t}\n\t}\n\tif !sawField {\n\t\treturn event, false, nil\n\t}\n\ttext := strings.Join(dataLines, "\\n")\n\tevent.Data = text\n\tif jsonData && text != "" {\n\t\tvar parsed any\n\t\tif err := json.Unmarshal([]byte(text), &parsed); err != nil {\n\t\t\treturn event, false, err\n\t\t}\n\t\tevent.Data = parsed\n\t}\n\treturn event, true, nil\n}\n\n// iterSSE streams events, reconnecting on dropped connections with Last-Event-ID\n// (a fresh open call = fresh auth); a 4xx/5xx or a bad JSON payload is definitive.\nfunc iterSSE(open func(extraHeaders map[string]string) (*http.Response, error), jsonData bool) func(yield func(ServerSentEvent, error) bool) {\n\treturn func(yield func(ServerSentEvent, error) bool) {\n\t\tlastEventID := ""\n\t\tserverRetry := -1\n\t\tfailures := 0\n\t\tfor {\n\t\t\theaders := map[string]string{"Accept": "text/event-stream"}\n\t\t\tif lastEventID != "" {\n\t\t\t\theaders["Last-Event-ID"] = lastEventID\n\t\t\t}\n\t\t\tresp, err := open(headers)\n\t\t\tif err == nil && resp.StatusCode >= 400 {\n\t\t\t\tyield(ServerSentEvent{}, apiErrorFrom(resp, ""))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err == nil {\n\t\t\t\tfailures = 0\n\t\t\t\tbuffer := ""\n\t\t\t\tchunk := make([]byte, 4096)\n\t\t\t\tclean := false\n\t\t\t\tfor {\n\t\t\t\t\tn, readErr := resp.Body.Read(chunk)\n\t\t\t\t\tbuffer += string(chunk[:n])\n\t\t\t\t\tfor {\n\t\t\t\t\t\tframe, rest, found := strings.Cut(buffer, "\\n\\n")\n\t\t\t\t\t\tif !found {\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t\tbuffer = rest\n\t\t\t\t\t\tevent, ok, parseErr := parseSSEFrame(frame, jsonData)\n\t\t\t\t\t\tif parseErr != nil {\n\t\t\t\t\t\t\tresp.Body.Close()\n\t\t\t\t\t\t\tyield(ServerSentEvent{}, parseErr)\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif ok {\n\t\t\t\t\t\t\tif event.ID != "" {\n\t\t\t\t\t\t\t\tlastEventID = event.ID\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif event.Retry >= 0 {\n\t\t\t\t\t\t\t\tserverRetry = event.Retry\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif !yield(event, nil) {\n\t\t\t\t\t\t\t\tresp.Body.Close()\n\t\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif readErr == io.EOF {\n\t\t\t\t\t\tclean = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tif readErr != nil {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tresp.Body.Close()\n\t\t\t\tif clean {\n\t\t\t\t\tif strings.TrimSpace(buffer) != "" {\n\t\t\t\t\t\tif event, ok, parseErr := parseSSEFrame(buffer, jsonData); parseErr == nil && ok {\n\t\t\t\t\t\t\tyield(event, nil)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tfailures++\n\t\t\tbase := time.Second\n\t\t\tif serverRetry >= 0 {\n\t\t\t\tbase = time.Duration(serverRetry) * time.Millisecond\n\t\t\t}\n\t\t\tdelay := base * time.Duration(1<<(failures-1))\n\t\t\tif delay > 30*time.Second {\n\t\t\t\tdelay = 30 * time.Second\n\t\t\t}\n\t\t\ttime.Sleep(time.Duration(rand.Int63n(int64(delay) + 1)))\n\t\t}\n\t}\n}\n\n// ─── Multipart ───\n\n// toMultipart splits a typed body into a multipart/form-data payload: []byte\n// values upload as file parts, everything else as form fields (nested values\n// JSON-encoded) — mirroring the TypeScript runtime\'s FormData serialization.\nfunc toMultipart(body any) (string, io.Reader, error) {\n\tvar wire map[string]any\n\tif err := reencode(body, &wire); err != nil {\n\t\treturn "", nil, err\n\t}\n\tbuffer := &bytes.Buffer{}\n\twriter := multipart.NewWriter(buffer)\n\tfor key, value := range wire {\n\t\tswitch typed := value.(type) {\n\t\tcase string:\n\t\t\tif err := writer.WriteField(key, typed); err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\tcase float64, bool:\n\t\t\tif err := writer.WriteField(key, fmt.Sprint(typed)); err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\tdefault:\n\t\t\tencoded, err := json.Marshal(typed)\n\t\t\tif err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\t\tif err := writer.WriteField(key, string(encoded)); err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\t}\n\t}\n\tif err := writer.Close(); err != nil {\n\t\treturn "", nil, err\n\t}\n\treturn writer.FormDataContentType(), buffer, nil\n}\n'; diff --git a/packages/client-generator/src/emitters/identifier.ts b/packages/client-generator/src/emitters/identifier.ts index 71967dfdf7..1130855593 100644 --- a/packages/client-generator/src/emitters/identifier.ts +++ b/packages/client-generator/src/emitters/identifier.ts @@ -43,6 +43,16 @@ const TS_RESERVED = new Set([ '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). */ diff --git a/packages/client-generator/src/emitters/inline-runtime.ts b/packages/client-generator/src/emitters/inline-runtime.ts index 519c67e56c..efc7e87c10 100644 --- a/packages/client-generator/src/emitters/inline-runtime.ts +++ b/packages/client-generator/src/emitters/inline-runtime.ts @@ -1,11 +1,11 @@ // Assembles the embedded runtime block for inline-mode clients: the real -// `src/runtime/` sources (snapshotted into `RUNTIME_SOURCES`) in import-graph -// order, stripped of module syntax, followed by a local `createClient` factory -// wiring only the capabilities this API needs — the embedded equivalent of the -// package barrel (`runtime/index.ts`), which is never embedded itself. +// `src/runtime/` sources — stripped of module syntax at PREPARE time (see +// scripts/generate-runtime-sources.mjs, which owns the kept-export surface) — in +// import-graph order, followed by a local `createClient` factory wiring only the +// capabilities this API needs. Pure string concatenation: no `typescript` at +// generate time. -import { RUNTIME_SOURCES, type RuntimeModuleName } from './runtime-sources.js'; -import { parseStatements, ts } from './ts.js'; +import { RUNTIME_SOURCES_STRIPPED, type RuntimeModuleName } from './runtime-sources.js'; /** Which optional runtime capabilities the generated client must embed. */ export type InlineRuntimeNeeds = { @@ -19,19 +19,6 @@ export type InlineRuntimeNeeds = { const HEADER = "// ─── Embedded runtime (@redocly/client-generator, assembled per this API's needs) ───"; -// The embedded block keeps `export` only on the surface the generated wiring and its -// type re-exports reference; everything else becomes module-local. `types.ts` is the -// public type surface (it replaces package-mode type imports — and TS `noUnusedLocals` -// never flags exported declarations, so unused types in a given output are fine). -const KEEP_EXPORTS: Partial boolean>> = { - 'types.ts': () => true, - 'errors.ts': ts.isClassDeclaration, // ApiError/TimeoutError stay public; abortError goes local - // defaultRetryOn stays public so custom `retryOn` predicates can compose with it. - 'retry.ts': (statement) => - ts.isFunctionDeclaration(statement) && statement.name?.text === 'defaultRetryOn', - 'setup.ts': () => true, // mergeSetup — the baked-setup wiring calls it -}; - /** 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 @@ -46,34 +33,16 @@ export function assembleInlineRuntime(needs: InlineRuntimeNeeds): string { modules.push('send.ts'); if (needs.sse) modules.push('sse.ts'); modules.push('create-client.ts'); - return [HEADER, ...modules.map(embedModule), clientFactory(needs)].join('\n\n'); + return [ + HEADER, + ...modules.map((name) => RUNTIME_SOURCES_STRIPPED[name]), + clientFactory(needs), + ].join('\n\n'); } -// Strip module syntax from one runtime source: drop every import declaration (all are -// relative `./x.js` imports within the runtime) and remove the `export` modifier from -// declarations outside the kept surface. Slices are driven by AST positions from -// `parseStatements` (no regexes), so comments and formatting survive byte-for-byte. -function embedModule(name: RuntimeModuleName): string { - const source = RUNTIME_SOURCES[name]; - const keeps = KEEP_EXPORTS[name]; - const parts: string[] = []; - for (const statement of parseStatements(source)) { - if (ts.isImportDeclaration(statement)) continue; - // Full text includes leading trivia (JSDoc, blank lines), so spacing is preserved. - const text = source.slice(statement.getFullStart(), statement.end); - // Every top-level runtime statement is a declaration (`getModifiers` is total — - // it returns undefined when the node carries no modifiers). - const exportModifier = ts - .getModifiers(statement as ts.HasModifiers) - ?.find((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); - if (exportModifier && !keeps?.(statement)) { - const at = exportModifier.getStart() - statement.getFullStart(); - parts.push(text.slice(0, at) + text.slice(at + 'export '.length)); - } else { - parts.push(text); - } - } - return parts.join('').trim(); +/** The cli engine (`runCli` + types) stripped for embedding into `.cli.ts`. */ +export function embedCliRuntime(): string { + return RUNTIME_SOURCES_STRIPPED['cli.ts']; } // The embedded equivalent of the package barrel's `createClient`: `createClientCore` diff --git a/packages/client-generator/src/emitters/jsdoc.ts b/packages/client-generator/src/emitters/jsdoc.ts index 1e824fc47e..c9a7f304b8 100644 --- a/packages/client-generator/src/emitters/jsdoc.ts +++ b/packages/client-generator/src/emitters/jsdoc.ts @@ -1,6 +1,10 @@ import type { SchemaMetadata } from '../intermediate-representation/model.js'; import { splitLines } from './support.js'; -import { escapeJsDoc } from './ts.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, diff --git a/packages/client-generator/src/emitters/mock-value.ts b/packages/client-generator/src/emitters/mock-value.ts new file mode 100644 index 0000000000..b8412c17c2 --- /dev/null +++ b/packages/client-generator/src/emitters/mock-value.ts @@ -0,0 +1,60 @@ +// The value tree the mock/faker emitters build and render: keeps object structure +// (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'; + +export type MockEntry = { key: string; value: MockValue } | { spread: string }; + +export type MockValue = + | { kind: 'object'; entries: MockEntry[] } + | { kind: 'array'; items: MockValue[] } + | { kind: 'expr'; text: string } + /** A textual wrapper around a nested value (`faker.helpers.multiple(() => , …)`). */ + | { kind: 'wrap'; before: string; value: MockValue; after: string }; + +export const expr = (text: string): MockValue => ({ kind: 'expr', text }); +export const objectValue = (entries: MockEntry[]): MockValue => ({ kind: 'object', entries }); + +export function isObjectValue(value: MockValue): value is Extract { + return value.kind === 'object'; +} + +/** Spread `` into an object value; non-objects pass through unchanged. */ +export function spreadInto(value: MockValue, name: string): MockValue { + if (!isObjectValue(value)) return value; + return objectValue([...value.entries, { spread: name }]); +} + +const INDENT = ' '; + +/** Render at `indent` (the containing line's indent): objects/arrays multiline, printer-style. */ +export function renderMockValue(value: MockValue, indent: string): string { + switch (value.kind) { + case 'expr': + return value.text; + case 'wrap': + return `${value.before}${renderMockValue(value.value, indent)}${value.after}`; + case 'array': { + if (value.items.length === 0) return '[]'; + const inner = indent + INDENT; + const lines = value.items.map( + (item, index) => + `${inner}${renderMockValue(item, inner)}${index === value.items.length - 1 ? '' : ','}` + ); + return `[\n${lines.join('\n')}\n${indent}]`; + } + case 'object': { + if (value.entries.length === 0) return '{}'; + const inner = indent + INDENT; + const lines = value.entries.map((entry, index) => { + const comma = index === value.entries.length - 1 ? '' : ','; + if ('spread' in entry) return `${inner}...${entry.spread}${comma}`; + const key = safeIdent(entry.key) === entry.key ? entry.key : sanitizeCodeString(entry.key); + return `${inner}${key}: ${renderMockValue(entry.value, inner)}${comma}`; + }); + return `{\n${lines.join('\n')}\n${indent}}`; + } + } +} diff --git a/packages/client-generator/src/emitters/mock.ts b/packages/client-generator/src/emitters/mock.ts index 361b3c3ccd..cb9f6c8770 100644 --- a/packages/client-generator/src/emitters/mock.ts +++ b/packages/client-generator/src/emitters/mock.ts @@ -1,9 +1,9 @@ // Emits a `*.mocks.ts` module: a `create(overrides?)` data factory per // named schema, an `Handler(override?)` MSW request handler per operation // (its primary success response), and an aggregated `handlers` array. Response -// data is sampled at codegen time (`sampleValue`) and printed as -// TypeScript literals through `ts.factory`, so the generated module depends only -// on `msw` — the real client stays zero-dependency. +// data is sampled at codegen time (`sampleValue`) and printed as TypeScript +// literals — source-text templates — so the generated module depends only on +// `msw`; the real client stays zero-dependency. import { isPlainObject } from '@redocly/openapi-core'; @@ -16,13 +16,21 @@ import { type SchemaModel, } from '../intermediate-representation/model.js'; import { fakerExpression } from './faker.js'; -import { safeIdent } from './identifier.js'; +import { isIdentifier } from './identifier.js'; +import { + expr, + isObjectValue, + type MockValue, + objectValue, + renderMockValue, + spreadInto, +} from './mock-value.js'; import { sampleValue, SampleExpression } from './sample.js'; import { pascalCase } from './support.js'; -import { literalExpression, parseExpression, printStatements, ts } from './ts.js'; +import { codeLiteral } from './ts-literal.js'; import type { DateType } from './types.js'; -const { factory } = ts; +const INDENT = ' '; export type MockOptions = { /** Import specifier for the sdk entry the schema types live in. */ @@ -42,11 +50,11 @@ export type MockOptions = { mockSeed?: number; }; -/** The body expression for `schema` under the active data mode: a static literal tree +/** The body value for `schema` under the active data mode: a static literal tree * (`'static'`) or a tree of `@faker-js/faker` calls (`'faker'`). Both honor `dateType` * and the binary/Blob type demand; the faker path inlines refs with the same cycle * guard as the static sampler, so neither recurses forever on a cyclic schema. */ -function bodyExpression(schema: SchemaModel, model: ApiModel, opts: MockOptions): ts.Expression { +function bodyValue(schema: SchemaModel, model: ApiModel, opts: MockOptions): MockValue { return opts.mockData === 'faker' ? fakerExpression(schema, model.schemas, { dateType: opts.dateType }) : literal(sampleValue(schema, model.schemas, { dateType: opts.dateType })); @@ -56,35 +64,22 @@ function bodyExpression(schema: SchemaModel, model: ApiModel, opts: MockOptions) export function renderMockModule(model: ApiModel, opts: MockOptions): string { const operations = allOperations(model.services); if (operations.length === 0) return ''; - const factories = model.schemas.map((s) => factoryFor(s, model, opts)); - const handlers = operations.flatMap((op) => [ - handlerFor(op, model, opts), - ...(op.errorResponses.length > 0 ? [errorHandlerFor(op, model, opts)] : []), - ]); - const typeImport = schemaTypeImport(model, opts); - // Faker mode imports `faker` (the consumer's dev-dep) and, with a seed, pins it once - // at module top so every run reproduces. Static mode emits neither (stays zero-dep). - const fakerImport = opts.mockData === 'faker' ? "import { faker } from '@faker-js/faker';\n" : ''; - const seed = - opts.mockData === 'faker' && opts.mockSeed !== undefined ? [seedStatement(opts.mockSeed)] : []; - return `import { http, HttpResponse } from 'msw';\n${fakerImport}\n${printStatements([ - ...typeImport, - ...seed, - ...factories, - ...handlers, + const blocks = [ + ...schemaTypeImport(model, opts), + // Faker mode imports `faker` (the consumer's dev-dep) and, with a seed, pins it once + // at module top so every run reproduces. Static mode emits neither (stays zero-dep). + ...(opts.mockData === 'faker' && opts.mockSeed !== undefined + ? [`faker.seed(${opts.mockSeed});`] + : []), + ...model.schemas.map((s) => factoryFor(s, model, opts)), + ...operations.flatMap((op) => [ + handlerFor(op, model, opts), + ...(op.errorResponses.length > 0 ? [errorHandlerFor(op, model, opts)] : []), + ]), handlersArray(operations), - ])}`; -} - -/** `faker.seed();` — pins faker's PRNG so a seeded faker-mode module reproduces. */ -function seedStatement(seed: number): ts.Statement { - return factory.createExpressionStatement( - factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('faker'), 'seed'), - undefined, - [factory.createNumericLiteral(seed)] - ) - ); + ]; + const fakerImport = opts.mockData === 'faker' ? "import { faker } from '@faker-js/faker';\n" : ''; + return `import { http, HttpResponse } from 'msw';\n${fakerImport}\n${blocks.join('\n\n')}`; } /** @@ -94,26 +89,12 @@ function seedStatement(seed: number): ts.Statement { * the schema types also shadows globals of the same name (e.g. an `Error` schema) so the * factory return types resolve to the generated type, not `globalThis.Error`. */ -function schemaTypeImport(model: ApiModel, opts: MockOptions): ts.Statement[] { +function schemaTypeImport(model: ApiModel, opts: MockOptions): string[] { if (model.schemas.length === 0) return []; // Verbatim, not PascalCased: the sdk exports each schema type under its emitted name // (`pet` stays `pet`), and the import must match it exactly. const names = model.schemas.map((s) => s.name).sort(); - return [ - factory.createImportDeclaration( - undefined, - factory.createImportClause( - true, - undefined, - factory.createNamedImports( - names.map((name) => - factory.createImportSpecifier(false, undefined, factory.createIdentifier(name)) - ) - ) - ), - factory.createStringLiteral(opts.sdkModule) - ), - ]; + return [`import type { ${names.join(', ')} } from ${JSON.stringify(opts.sdkModule)};`]; } /** @@ -122,62 +103,49 @@ function schemaTypeImport(model: ApiModel, opts: MockOptions): ts.Statement[] { * to spread into — `Partial` is meaningless and would silently drop the argument — * so its factory takes the FULL type and returns the override wholesale (`overrides ?? sample`). */ -function factoryFor(named: NamedSchemaModel, model: ApiModel, opts: MockOptions): ts.Statement { +function factoryFor(named: NamedSchemaModel, model: ApiModel, opts: MockOptions): string { const pascal = pascalCase(named.name); - const sampled = bodyExpression(named.schema, model, opts); + const sampled = bodyValue(named.schema, model, opts); // Type references use the sdk's verbatim export name; only the factory NAME is PascalCased. - const typeRef = factory.createTypeReferenceNode(named.name); - const spreads = ts.isObjectLiteralExpression(sampled); + const typeName = named.name; + const spreads = isObjectValue(sampled); // Spreading `Partial` (the override type of a union schema) distributes into // `Partial | Partial`, which widens any discriminant property (e.g. `category`) // and defeats narrowing — TS can no longer place the literal in a single union member. // The sampled object is already a complete, correct member, so re-assert the type. + const rendered = renderMockValue(spreads ? spreadInto(sampled, 'overrides') : sampled, INDENT); const body = !spreads - ? factory.createBinaryExpression( - factory.createIdentifier('overrides'), - factory.createToken(ts.SyntaxKind.QuestionQuestionToken), - sampled - ) + ? `overrides ?? ${rendered}` : named.schema.kind === 'union' - ? factory.createAsExpression(spreadOverrides(sampled, 'overrides'), typeRef) - : spreadOverrides(sampled, 'overrides'); - return factory.createFunctionDeclaration( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - undefined, - `create${pascal}`, - undefined, - [ - factory.createParameterDeclaration( - undefined, - undefined, - 'overrides', - factory.createToken(ts.SyntaxKind.QuestionToken), - spreads ? factory.createTypeReferenceNode('Partial', [typeRef]) : typeRef - ), - ], - typeRef, - factory.createBlock([factory.createReturnStatement(body)], true) - ); + ? `${rendered} as ${typeName}` + : rendered; + const overridesType = spreads ? `Partial<${typeName}>` : typeName; + return [ + `export function create${pascal}(overrides?: ${overridesType}): ${typeName} {`, + `${INDENT}return ${body};`, + '}', + ].join('\n'); +} + +/** + * The interpolation gate for values that land in emitted CODE positions (binding + * names, `http.` member access). The pipeline sanitizes operation names + * before any emitter runs; this re-checks at the construction site so a hostile + * name can never become code even if that invariant regresses. + */ +function codeIdent(value: string): string { + if (!isIdentifier(value)) { + throw new Error(`Unsafe identifier in mock emission: ${JSON.stringify(value)}`); + } + return value; } /** `export const Handler = (override?: ) => http.('', () => );`. */ -function handlerFor(op: OperationModel, model: ApiModel, opts: MockOptions): ts.Statement { +function handlerFor(op: OperationModel, model: ApiModel, opts: MockOptions): string { const override = overrideParam(op, model, opts); - const arrow = factory.createArrowFunction( - undefined, - undefined, - override ? [override] : [], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - handlerCall(op, model, opts) - ); - return factory.createVariableStatement( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - factory.createVariableDeclarationList( - [factory.createVariableDeclaration(`${op.name}Handler`, undefined, undefined, arrow)], - ts.NodeFlags.Const - ) - ); + const params = override ?? ''; + const call = `http.${codeIdent(op.method)}(${JSON.stringify(mswPath(op.path))}, () => ${responseExpression(op, model, opts)})`; + return `export const ${codeIdent(op.name)}Handler = (${params}) => ${call};`; } /** @@ -189,67 +157,12 @@ function handlerFor(op: OperationModel, model: ApiModel, opts: MockOptions): ts. * (plus `number` when a `default` error is present, so any status is allowed). The static * fallback samples the FIRST error response's schema. */ -function errorHandlerFor(op: OperationModel, model: ApiModel, opts: MockOptions): ts.Statement { +function errorHandlerFor(op: OperationModel, model: ApiModel, opts: MockOptions): string { const first = op.errorResponses[0]; - const sampled = bodyExpression(first.schema, model, opts); - const body = factory.createBinaryExpression( - factory.createIdentifier('body'), - factory.createToken(ts.SyntaxKind.QuestionQuestionToken), - sampled - ); - const resolver = factory.createArrowFunction( - undefined, - undefined, - [], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('HttpResponse'), 'json'), - undefined, - [ - body, - factory.createObjectLiteralExpression( - [factory.createShorthandPropertyAssignment('status')], - false - ), - ] - ) - ); - const call = factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('http'), op.method), - undefined, - [factory.createStringLiteral(mswPath(op.path)), resolver] - ); - const arrow = factory.createArrowFunction( - undefined, - undefined, - [ - factory.createParameterDeclaration( - undefined, - undefined, - 'status', - undefined, - errorStatusType(op) - ), - factory.createParameterDeclaration( - undefined, - undefined, - 'body', - factory.createToken(ts.SyntaxKind.QuestionToken), - errorBodyType(op) - ), - ], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - call - ); - return factory.createVariableStatement( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - factory.createVariableDeclarationList( - [factory.createVariableDeclaration(`${op.name}ErrorHandler`, undefined, undefined, arrow)], - ts.NodeFlags.Const - ) - ); + const sampled = renderMockValue(bodyValue(first.schema, model, opts), ''); + const resolver = `() => HttpResponse.json(body ?? ${sampled}, { status })`; + const call = `http.${codeIdent(op.method)}(${JSON.stringify(mswPath(op.path))}, ${resolver})`; + return `export const ${codeIdent(op.name)}ErrorHandler = (status: ${errorStatusType(op)}, body?: ${errorBodyType(op)}) => ${call};`; } /** @@ -257,20 +170,18 @@ function errorHandlerFor(op: OperationModel, model: ApiModel, opts: MockOptions) * of a literal whenever a `default` error or a `4XX`/`5XX` range is present, so any status is * accepted. De-duped, since a multi-media-type error contributes the same status more than once. */ -function errorStatusType(op: OperationModel): ts.TypeNode { +function errorStatusType(op: OperationModel): string { const codes = [ ...new Set( op.errorResponses.filter((r) => typeof r.status === 'number').map((r) => r.status as number) ), ]; - const members: ts.TypeNode[] = codes.map((c) => - factory.createLiteralTypeNode(factory.createNumericLiteral(c)) - ); + const members: string[] = codes.map(String); // A `default` error (or a range wildcard) means any status is valid — widen with `number`. if (op.errorResponses.some((r) => typeof r.status !== 'number')) { - members.push(factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword)); + members.push('number'); } - return members.length === 1 ? members[0] : factory.createUnionTypeNode(members); + return members.join(' | '); } /** @@ -278,18 +189,16 @@ function errorStatusType(op: OperationModel): ts.TypeNode { * named type, anything else to `unknown` (matching how the success handler types its override * loosely). De-duped by printed name. */ -function errorBodyType(op: OperationModel): ts.TypeNode { +function errorBodyType(op: OperationModel): string { const names = new Set(); let hasUnknown = false; for (const r of op.errorResponses) { if (r.schema.kind === 'ref') names.add(r.schema.name); else hasUnknown = true; } - const members: ts.TypeNode[] = [...names].map((n) => factory.createTypeReferenceNode(n)); - if (hasUnknown || members.length === 0) { - members.push(factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword)); - } - return members.length === 1 ? members[0] : factory.createUnionTypeNode(members); + const members = [...names]; + if (hasUnknown || members.length === 0) members.push('unknown'); + return members.join(' | '); } /** @@ -300,52 +209,18 @@ function errorBodyType(op: OperationModel): ts.TypeNode { * `Record`. A body-less or non-object inline response has nothing to * override, so the handler takes no parameter. */ -function overrideParam( - op: OperationModel, - model: ApiModel, - opts: MockOptions -): ts.ParameterDeclaration | undefined { +function overrideParam(op: OperationModel, model: ApiModel, opts: MockOptions): string | undefined { const success = op.successResponses[0]; if (!success || success.schema.kind === 'unknown') return undefined; - let type: ts.TypeNode; if (success.schema.kind === 'ref') { - const typeRef = factory.createTypeReferenceNode(success.schema.name); - type = ts.isObjectLiteralExpression(bodyExpression(success.schema, model, opts)) - ? factory.createTypeReferenceNode('Partial', [typeRef]) - : typeRef; - } else { - if (!ts.isObjectLiteralExpression(bodyExpression(success.schema, model, opts))) { - return undefined; - } - type = factory.createTypeReferenceNode('Record', [ - factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword), - ]); + const typeName = success.schema.name; + const type = isObjectValue(bodyValue(success.schema, model, opts)) + ? `Partial<${typeName}>` + : typeName; + return `override?: ${type}`; } - return factory.createParameterDeclaration( - undefined, - undefined, - 'override', - factory.createToken(ts.SyntaxKind.QuestionToken), - type - ); -} - -/** `http.('', () => )`. */ -function handlerCall(op: OperationModel, model: ApiModel, opts: MockOptions): ts.Expression { - const resolver = factory.createArrowFunction( - undefined, - undefined, - [], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - responseExpression(op, model, opts) - ); - return factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('http'), op.method), - undefined, - [factory.createStringLiteral(mswPath(op.path)), resolver] - ); + if (!isObjectValue(bodyValue(success.schema, model, opts))) return undefined; + return 'override?: Record'; } /** @@ -356,25 +231,19 @@ function handlerCall(op: OperationModel, model: ApiModel, opts: MockOptions): ts * body-less `new HttpResponse(null, { status })`. The status is the success * response's declared code, or 200 when it's `default`/absent. */ -function responseExpression(op: OperationModel, model: ApiModel, opts: MockOptions): ts.Expression { +function responseExpression(op: OperationModel, model: ApiModel, opts: MockOptions): string { const success = op.successResponses[0]; const status = statusCode(success?.status); - if (!success || success.schema.kind === 'unknown') return emptyResponse(status); + if (!success || success.schema.kind === 'unknown') { + return `new HttpResponse(null, { status: ${status} })`; + } const data = success.schema.kind === 'ref' - ? factory.createCallExpression( - factory.createIdentifier(`create${pascalCase(success.schema.name)}`), - undefined, - [factory.createIdentifier('override')] - ) - : spreadOverrides(bodyExpression(success.schema, model, opts), 'override'); + ? `create${pascalCase(success.schema.name)}(override)` + : renderMockValue(spreadInto(bodyValue(success.schema, model, opts), 'override'), ''); // `HttpResponse.json(x)` already defaults to 200, so only pass `{ status }` when it differs. - const args = status === 200 ? [data] : [data, statusInit(status)]; - return factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('HttpResponse'), 'json'), - undefined, - args - ); + const args = status === 200 ? data : `${data}, { status: ${status} }`; + return `HttpResponse.json(${args})`; } /** Numeric status for a response, mapping `default`/absent to 200. */ @@ -382,50 +251,10 @@ function statusCode(status: ResponseBodyModel['status'] | undefined): number { return typeof status === 'number' ? status : 200; } -/** `{ status: }`. */ -function statusInit(status: number): ts.Expression { - return factory.createObjectLiteralExpression( - [factory.createPropertyAssignment('status', factory.createNumericLiteral(status))], - false - ); -} - -/** `new HttpResponse(null, { status: })`. */ -function emptyResponse(status: number): ts.Expression { - return factory.createNewExpression(factory.createIdentifier('HttpResponse'), undefined, [ - factory.createNull(), - statusInit(status), - ]); -} - /** `export const handlers = [Handler(), …];`. */ -function handlersArray(operations: OperationModel[]): ts.Statement { - const elements = operations.map((op) => - factory.createCallExpression(factory.createIdentifier(`${op.name}Handler`), undefined, []) - ); - return factory.createVariableStatement( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - factory.createVariableDeclarationList( - [ - factory.createVariableDeclaration( - 'handlers', - undefined, - undefined, - factory.createArrayLiteralExpression(elements, false) - ), - ], - ts.NodeFlags.Const - ) - ); -} - -/** Spread `` into an object literal; non-object values pass through unchanged. */ -function spreadOverrides(value: ts.Expression, spreadName: string): ts.Expression { - if (!ts.isObjectLiteralExpression(value)) return value; - return factory.createObjectLiteralExpression( - [...value.properties, factory.createSpreadAssignment(factory.createIdentifier(spreadName))], - true - ); +function handlersArray(operations: OperationModel[]): string { + const elements = operations.map((op) => `${codeIdent(op.name)}Handler()`).join(', '); + return `export const handlers = [${elements}];`; } /** `/pets/{petId}` → `*​/pets/:petId` — MSW path with a wildcard origin and `:param` segments. */ @@ -433,25 +262,15 @@ function mswPath(path: string): string { return `*${path.replace(/\{([^{}]+)\}/g, ':$1')}`; } -/** Recursively print a sampled JS value as a TypeScript literal expression. Containers - * stay local rather than delegating to the shared `literalExpression`: sampled trees - * print multiline and may nest a `SampleExpression` at any depth. */ -function literal(value: unknown): ts.Expression { - if (value instanceof SampleExpression) return parseExpression(value.code); - if (Array.isArray(value)) { - return factory.createArrayLiteralExpression(value.map(literal), true); - } +/** Recursively lift a sampled JS value into the render tree. Containers render + * multiline; a `SampleExpression` carries pre-built source (`new Date(...)`). */ +function literal(value: unknown): MockValue { + if (value instanceof SampleExpression) return expr(value.code); + if (Array.isArray(value)) return { kind: 'array', items: value.map(literal) }; if (isPlainObject(value)) { - const entries = Object.entries(value); - return factory.createObjectLiteralExpression( - entries.map(([key, v]) => { - const safe = safeIdent(key); - const name = - safe === key ? factory.createIdentifier(key) : factory.createStringLiteral(key); - return factory.createPropertyAssignment(name, literal(v)); - }), - true + return objectValue( + Object.entries(value).map(([key, entryValue]) => ({ key, value: literal(entryValue) })) ); } - return literalExpression(value); + return expr(codeLiteral(value)); } diff --git a/packages/client-generator/src/emitters/operation-aliases.ts b/packages/client-generator/src/emitters/operation-aliases.ts deleted file mode 100644 index 63bb34dd9f..0000000000 --- a/packages/client-generator/src/emitters/operation-aliases.ts +++ /dev/null @@ -1,277 +0,0 @@ -// The `*` derived type-alias builders (`Result`/`Error`/`Params`/`Body`/`Headers`/`Variables`). -// Split out of operations.ts: this is the cohesive cluster the sdk emits so callers can name -// intermediate values. Reuses the shared type builders (operation-types.ts) and the block-wide -// `EmitContext` (a type-only import from operations.ts — erased, so there is no runtime cycle). - -import type { OperationModel, ParamModel } from '../intermediate-representation/model.js'; -import { safeIdent } from './identifier.js'; -import { jsdocText } from './jsdoc.js'; -import { operationSignature } from './operation-signature.js'; -import { bodyTypeNode, paramsTypeLiteral, propertyKey } from './operation-types.js'; -import type { EmitContext } from './operations.js'; -import { responseHeadersTypeLiteral } from './response-headers.js'; -import { pascalCase } from './support.js'; -import { jsdoc, ts } from './ts.js'; -import { schemaToTypeNode } from './types.js'; - -const { factory } = ts; - -/** - * Emit derived type aliases for an operation so callers can name intermediate - * values without re-deriving via `Awaited>` plumbing. - * - * `*Result` is always emitted (even for `void`). The others are conditional on - * the operation actually having the corresponding inputs — emitting empty - * `*Params = {}` or `*Body = unknown` aliases would just be noise. - */ -export function renderOperationAliases( - op: OperationModel, - responseType: ts.TypeNode, - orderedPathParams: ParamModel[], - pathParamIdent: Map, - errorAlias: string, - errorMembers: ts.TypeNode[], - ctx: EmitContext, - // SSE ops have no one-shot response, so they omit `*Result`/`*Error` and keep only the input - // aliases. - emitResultAndError = true, - // Threaded to the `Variables` body — see `variablesTypeLiteral`. - pathKeys: 'ident' | 'wire' = 'ident' -): ts.Statement[] { - const { dateType, schemaNames } = ctx; - const name = pascalCase(op.name); - const aliases: ts.Statement[] = []; - - // Every derived alias is suppressed when its name collides with an exported schema (a - // duplicate `export type` is a TS2300 error). References to a suppressed alias inline the - // underlying type instead — see `renderOperationParts` (Result/Error) and `renderVariablesAlias` - // / the grouped signature (Params/Body/Headers/Variables). - - // Emit `export type Result = …` unless its name collides with an exported schema. Two - // cases collide: self-referential (operation `search` returning schema `SearchResult` → - // `export type SearchResult = SearchResult;`, circular) and plain (operation `login` returning - // some other type while a `LoginResult` schema also exists). In both, call sites reference the - // response type directly (`renderOperationParts`), so the alias is redundant. - const resultName = `${name}Result`; - if (emitResultAndError && !schemaNames.has(resultName)) { - aliases.push(exportType(resultName, responseType)); - } - - // Result mode only, and only when the operation declares error responses: the typed `error`. - if (emitResultAndError && errorAlias && !schemaNames.has(errorAlias)) { - aliases.push( - exportType( - errorAlias, - errorMembers.length === 1 ? errorMembers[0] : factory.createUnionTypeNode(errorMembers) - ) - ); - } - - if (op.queryParams.length > 0 && !schemaNames.has(`${name}Params`)) { - // Reuse the params type-literal builder so the alias body picks up per-prop - // JSDoc automatically — no second renderer to keep in sync. - aliases.push(exportType(`${name}Params`, paramsTypeLiteral(op.queryParams, dateType))); - } - - if (op.requestBody && !schemaNames.has(`${name}Body`)) { - // Use the same content-type → TS-type mapping as the function signature, so - // `Body` matches the second-positional arg of the function exactly. - aliases.push(exportType(`${name}Body`, bodyTypeNode(op.requestBody, dateType))); - } - - if (op.headerParams.length > 0 && !schemaNames.has(`${name}Headers`)) { - aliases.push(exportType(`${name}Headers`, paramsTypeLiteral(op.headerParams, dateType))); - } - - // Response headers (envelope) — distinct from request `Headers`. - const responseHeaders = op.successResponseHeaders; - if (responseHeaders && responseHeaders.length > 0 && !schemaNames.has(`${name}ResponseHeaders`)) { - aliases.push( - exportType(`${name}ResponseHeaders`, responseHeadersTypeLiteral(responseHeaders, ctx.schemas)) - ); - } - - if (op.cookieParams.length > 0 && !schemaNames.has(`${name}Cookies`)) { - aliases.push(exportType(`${name}Cookies`, paramsTypeLiteral(op.cookieParams, dateType))); - } - - if (!schemaNames.has(`${name}Variables`)) { - const variables = renderVariablesAlias( - op, - name, - orderedPathParams, - pathParamIdent, - ctx, - pathKeys - ); - if (variables) aliases.push(variables); - } - - return aliases; -} - -/** - * An SSE op's input aliases (`*Params` / `*Body` / `*Headers` / `*Variables`) — but NOT - * `*Result`/`*Error`, which describe a one-shot response an event stream has no equivalent of. - * Passes the real `schemaNames` so the input aliases still get collision suppression, and sets - * `emitResultAndError = false` to omit the result/error pair. - */ -export function sseAliases( - op: OperationModel, - orderedPathParams: ParamModel[], - pathParamIdent: Map, - ctx: EmitContext, - pathKeys: 'ident' | 'wire' = 'ident' -): ts.Statement[] { - return renderOperationAliases( - op, - factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword), - orderedPathParams, - pathParamIdent, - '', - [], - ctx, - false, - pathKeys - ); -} - -/** `export type = ;` */ -function exportType(name: string, type: ts.TypeNode): ts.TypeAliasDeclaration { - return factory.createTypeAliasDeclaration( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - name, - undefined, - type - ); -} - -/** - * Combined inputs alias — a single object that bundles every positional input - * the operation function accepts. The seam React Query / SWR wrappers use: - * - * useMutation({ mutationFn: (vars: UpdateOrderVariables) => updateOrder(vars.orderId, vars.params, vars.body) }) - * - * Conventions: - * - Property order: path params (URL-template order), then `params`, then `body`, - * then `headers`. Mirrors the function's positional argument order. - * - Path-param props carry the same JSDoc (description + schema metadata) the - * function declaration omits. - * - `params?` is optional iff the function signature defaults to `= {}` (all query - * params optional). Same rule for `body?` / `headers?`. - * - References `Params` / `Body` / `Headers` when those aliases were - * also emitted, so the aliases stay in sync without duplicating their bodies. - * - * Returns `undefined` for operations with no inputs at all. - */ -function renderVariablesAlias( - op: OperationModel, - name: string, - orderedPathParams: ParamModel[], - pathParamIdent: Map, - ctx: EmitContext, - pathKeys: 'ident' | 'wire' -): ts.TypeAliasDeclaration | undefined { - if (!operationSignature(op).hasInputs) return undefined; - return exportType( - name + 'Variables', - variablesTypeLiteral(op, name, orderedPathParams, pathParamIdent, ctx, pathKeys) - ); -} - -/** - * The `Variables` object type literal (the body of the alias, reused inline by the grouped - * signature when the alias name itself collides). Each `params`/`body`/`headers` property - * references its `X` alias, or inlines the type when that alias name collides with a schema - * (so a suppressed alias is never referenced). - */ -export function variablesTypeLiteral( - op: OperationModel, - name: string, - orderedPathParams: ParamModel[], - pathParamIdent: Map, - ctx: EmitContext, - // How path-param properties are keyed: `'ident'` (default) uses the sanitized identifier - // that doubles as the flat positional argument; `'wire'` (package mode) uses the spec's - // param name — quoted when needed — because the runtime routes `args[param.name]`. - pathKeys: 'ident' | 'wire' = 'ident' -): ts.TypeNode { - const { dateType, schemaNames } = ctx; - const props: ts.PropertySignature[] = []; - - for (const p of orderedPathParams) { - // Ident mode: same safe identifier the function uses, so a wrapper can map - // `vars.` straight onto the positional argument. - const sig = factory.createPropertySignature( - undefined, - pathKeys === 'wire' ? propertyKey(safeIdent(p.name)) : pathParamIdent.get(p.name)!, - undefined, - schemaToTypeNode(p.schema, dateType) - ); - const doc = jsdocText(p.description, p.schema.metadata); - props.push(doc === undefined ? sig : jsdoc(sig, doc)); - } - - if (op.queryParams.length > 0) { - props.push( - inputProp( - 'params', - `${name}Params`, - () => paramsTypeLiteral(op.queryParams, dateType), - op.queryParams.some((p) => p.required), - schemaNames - ) - ); - } - if (op.requestBody) { - props.push( - inputProp( - 'body', - `${name}Body`, - () => bodyTypeNode(op.requestBody!, dateType), - op.requestBody.required, - schemaNames - ) - ); - } - if (op.headerParams.length > 0) { - props.push( - inputProp( - 'headers', - `${name}Headers`, - () => paramsTypeLiteral(op.headerParams, dateType), - op.headerParams.some((p) => p.required), - schemaNames - ) - ); - } - if (op.cookieParams.length > 0) { - props.push( - inputProp( - 'cookies', - `${name}Cookies`, - () => paramsTypeLiteral(op.cookieParams, dateType), - op.cookieParams.some((p) => p.required), - schemaNames - ) - ); - } - - return factory.createTypeLiteralNode(props); -} - -/** A `(?): ` property, or an inline-typed one when `` collides with a schema. */ -function inputProp( - key: string, - alias: string, - inlineType: () => ts.TypeNode, - required: boolean, - schemaNames: Set -): ts.PropertySignature { - return factory.createPropertySignature( - undefined, - key, - required ? undefined : factory.createToken(ts.SyntaxKind.QuestionToken), - schemaNames.has(alias) ? inlineType() : factory.createTypeReferenceNode(alias) - ); -} diff --git a/packages/client-generator/src/emitters/operation-signature.ts b/packages/client-generator/src/emitters/operation-signature.ts index e7e9693a26..fe9224a95e 100644 --- a/packages/client-generator/src/emitters/operation-signature.ts +++ b/packages/client-generator/src/emitters/operation-signature.ts @@ -1,53 +1,47 @@ -// The shared calling-convention description for an operation. Both the sdk (which -// emits each operation's parameter list) and the wrapper generators (which emit the -// forwarding call) derive their argument *order*, slot presence, and `Variables` -// naming from this one source — so a flat-mode signature and its call site can never drift. +// The shared calling-convention description for an operation. The sdk (which emits each +// 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 { uniqueIdent } from './identifier.js'; import { pascalCase } from './support.js'; -/** A path parameter paired with the unique JS identifier used for it in flat mode. */ -export type SignaturePathParam = { param: ParamModel; ident: string }; - export type OperationSignature = { - /** Path params in URL-template order, each with its unique JS identifier. */ - pathParams: SignaturePathParam[]; - /** Slot presence, in the order flat-mode arguments follow the path params. */ + /** Slot presence — which input layers the operation has. */ hasQuery: boolean; hasBody: boolean; hasHeaders: boolean; hasCookies: boolean; /** Any input at all — i.e. a `Variables` type exists for the operation. */ hasInputs: boolean; - /** Grouped mode: whether `vars` is required (else it defaults to `= {}`). */ + /** Whether the input argument is required (else it defaults to `= {}`). */ varsRequired: boolean; /** The `Variables` type-alias name. */ variablesTypeName: string; }; -/** Compute the calling-convention description for `op`. Pure; no AST. */ -export function operationSignature(op: OperationModel): OperationSignature { - const byName = new Map(op.pathParams.map((p) => [p.name, p] as const)); +/** + * Path parameters in URL-template order — the order a reader sees them in the path, and the + * order the old positional signature used. A parameter declared but absent from the template + * is dropped: it has nowhere to go in the URL, so asking for a value would mislead. + */ +export function templatePathParams(op: OperationModel): ParamModel[] { + const byName = new Map(op.pathParams.map((param) => [param.name, param] as const)); const ordered: ParamModel[] = []; for (const match of op.path.matchAll(/\{([^{}]+)\}/g)) { - const p = byName.get(match[1]); - if (p) ordered.push(p); + const param = byName.get(match[1]); + if (param !== undefined) ordered.push(param); } - // Seed the slot/`init` argument names the flat signature appends after the path - // params: a same-named path param keeps its wire name but binds as `_2` - // (the sugar remaps `{ : }`). The slot names themselves are - // rejected at build time (`assertPathParamsAvoidArgSlots`); `init` is only a - // binding here, so the remap fully handles it. - const used = new Set(['params', 'body', 'headers', 'cookies', 'init']); - const pathParams = ordered.map((param) => ({ param, ident: uniqueIdent(param.name, used) })); + return ordered; +} +/** Compute the calling-convention description for `op`. Pure; no AST. */ +export function operationSignature(op: OperationModel): OperationSignature { + const pathParams = templatePathParams(op); const hasQuery = op.queryParams.length > 0; const hasBody = Boolean(op.requestBody); const hasHeaders = op.headerParams.length > 0; const hasCookies = op.cookieParams.length > 0; return { - pathParams, hasQuery, hasBody, hasHeaders, diff --git a/packages/client-generator/src/emitters/operation-types.ts b/packages/client-generator/src/emitters/operation-types.ts index 9b43a60e8b..9c670d038f 100644 --- a/packages/client-generator/src/emitters/operation-types.ts +++ b/packages/client-generator/src/emitters/operation-types.ts @@ -1,71 +1,6 @@ -// TypeScript type/parameter builders shared by the operation emitter and the operation-alias -// builders: turn an operation's params / body / responses into `ts` type and parameter nodes. -// Leaf module — depends only on the IR types and the emit foundation, never back on operations.ts. +// Shared operation-shape predicates. -import type { - ParamModel, - RequestBodyModel, - ResponseBodyModel, -} from '../intermediate-representation/model.js'; -import { safeIdent } from './identifier.js'; -import { jsdocText } from './jsdoc.js'; -import { jsdoc, printNodes, ts } from './ts.js'; -import { type DateType, schemaToTypeNode } from './types.js'; - -const { factory } = ts; - -/** A `: ` parameter, defaulting to `= {}` when `withDefault`. */ -export function simpleParam( - name: string, - type: ts.TypeNode, - withDefault: boolean -): ts.ParameterDeclaration { - return factory.createParameterDeclaration( - undefined, - undefined, - name, - undefined, - type, - withDefault ? factory.createObjectLiteralExpression([], false) : undefined - ); -} - -/** - * A `: { … }` argument bundling `params` into one object, each property - * carrying its own JSDoc (description + metadata). Defaults to `= {}` when every - * property is optional. Shared by the query `params` and the operation `headers` - * slots, which have the identical layout. - */ -export function renderParamsObjectArg( - slot: string, - params: ParamModel[], - dateType: DateType -): ts.ParameterDeclaration { - return simpleParam(slot, paramsTypeLiteral(params, dateType), !params.some((p) => p.required)); -} - -/** The `{ … }` type literal for a params object (query or headers), with per-prop JSDoc. */ -export function paramsTypeLiteral(params: ParamModel[], dateType: DateType): ts.TypeLiteralNode { - return factory.createTypeLiteralNode( - params.map((p) => { - const sig = factory.createPropertySignature( - undefined, - propertyKey(safeIdent(p.name)), - p.required ? undefined : factory.createToken(ts.SyntaxKind.QuestionToken), - schemaToTypeNode(p.schema, dateType) - ); - const doc = jsdocText(p.description, p.schema.metadata); - return doc === undefined ? sig : jsdoc(sig, doc); - }) - ); -} - -/** A bare identifier key when valid, a quoted string-literal key otherwise. */ -export function propertyKey(safe: string): ts.PropertyName { - return safe.startsWith('"') - ? factory.createStringLiteral(JSON.parse(safe) as string) - : factory.createIdentifier(safe); -} +import type { RequestBodyModel } from '../intermediate-representation/model.js'; /** * A multipart body whose schema is a concrete object — the case worth typing. Such a body @@ -76,80 +11,3 @@ export function propertyKey(safe: string): ts.PropertyName { export function isTypedMultipart(rb: RequestBodyModel): boolean { return rb.contentType === 'multipart/form-data' && rb.schema.kind === 'object'; } - -/** The request-body TS type: special wrapper types per content-type, else the schema. */ -export function bodyTypeNode(rb: RequestBodyModel, dateType: DateType): ts.TypeNode { - if (isTypedMultipart(rb)) return schemaToTypeNode(rb.schema, dateType); - switch (rb.contentType) { - case 'multipart/form-data': - return factory.createTypeReferenceNode('FormData'); - case 'application/x-www-form-urlencoded': - return factory.createTypeReferenceNode('URLSearchParams'); - case 'application/octet-stream': - return factory.createUnionTypeNode([ - factory.createTypeReferenceNode('Blob'), - factory.createTypeReferenceNode('ArrayBuffer'), - ]); - default: - return schemaToTypeNode(rb.schema, dateType); - } -} - -/** The deduped error-response body type nodes (by printed form), or `[]` when none. */ -export function errorTypeNodes(responses: ResponseBodyModel[], dateType: DateType): ts.TypeNode[] { - const seen = new Set(); - const nodes: ts.TypeNode[] = []; - for (const r of responses) { - const node = schemaToTypeNode(r.schema, dateType); - const key = printNodes([node]); - if (seen.has(key)) continue; - seen.add(key); - nodes.push(node); - } - return nodes; -} - -export function computeResponse( - responses: ResponseBodyModel[], - dateType: DateType -): { - responseType: ts.TypeNode; - responseKind: 'json' | 'blob' | 'text' | 'void'; -} { - if (responses.length === 0) - return { - responseType: factory.createKeywordTypeNode(ts.SyntaxKind.VoidKeyword), - responseKind: 'void', - }; - - // Prefer JSON; fall back to other content types. - const jsonResponse = responses.find((r) => r.contentType.toLowerCase().includes('json')); - if (jsonResponse) { - return { responseType: schemaToTypeNode(jsonResponse.schema, dateType), responseKind: 'json' }; - } - // No JSON — handle binary/text gracefully. - const nodes: ts.TypeNode[] = []; - const seen = new Set(); - let hasBinary = false; - let hasText = false; - for (const r of responses) { - let node: ts.TypeNode; - if (r.contentType.startsWith('image/') || r.contentType === 'application/octet-stream') { - node = factory.createTypeReferenceNode('Blob'); - hasBinary = true; - } else if (r.contentType.startsWith('text/')) { - node = factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); - hasText = true; - } else { - node = schemaToTypeNode(r.schema, dateType); - } - const key = printNodes([node]); - if (seen.has(key)) continue; - seen.add(key); - nodes.push(node); - } - // `nodes` is guaranteed non-empty here: each iteration above always builds one. - const responseType = nodes.length === 1 ? nodes[0] : factory.createUnionTypeNode(nodes); - const responseKind: 'blob' | 'text' | 'json' = hasBinary ? 'blob' : hasText ? 'text' : 'json'; - return { responseType, responseKind }; -} diff --git a/packages/client-generator/src/emitters/operations.ts b/packages/client-generator/src/emitters/operations.ts index 258c0f1fc1..1e5c169a56 100644 --- a/packages/client-generator/src/emitters/operations.ts +++ b/packages/client-generator/src/emitters/operations.ts @@ -1,15 +1,6 @@ -import type { - NamedSchemaModel, - OperationModel, - ParamModel, -} from '../intermediate-representation/model.js'; -import { bodyTypeNode, renderParamsObjectArg, simpleParam } from './operation-types.js'; +import type { NamedSchemaModel } from '../intermediate-representation/model.js'; import type { ModelPagination } from './pagination.js'; -import { isSseOp } from './sse.js'; -import { ts } from './ts.js'; -import { type DateType, schemaToTypeNode } from './types.js'; - -const { factory } = ts; +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'; @@ -42,54 +33,3 @@ export type EmitContext = { /** Resolved auto-pagination per operation name (absent ⇒ nothing paginates). */ pagination?: ModelPagination; }; - -/** - * The flat sugar's parameter list: path params spread as positional args (in URL - * template order), then the `params`/`body`/`headers`/`cookies` slots, ending with - * the trailing `init: RequestOptions` (`SseOptions` for streams). Optional slots - * default to `= {}` so trailing arguments can be omitted. - */ -export function renderArgList( - op: OperationModel, - orderedPathParams: ParamModel[], - pathParamIdent: Map, - ctx: EmitContext -): ts.ParameterDeclaration[] { - const { dateType } = ctx; - const args: ts.ParameterDeclaration[] = []; - for (const p of orderedPathParams) { - args.push( - simpleParam(pathParamIdent.get(p.name)!, schemaToTypeNode(p.schema, dateType), false) - ); - } - if (op.queryParams.length > 0) - args.push(renderParamsObjectArg('params', op.queryParams, dateType)); - if (op.requestBody) { - const type = bodyTypeNode(op.requestBody, dateType); - args.push( - factory.createParameterDeclaration( - undefined, - undefined, - 'body', - op.requestBody.required ? undefined : factory.createToken(ts.SyntaxKind.QuestionToken), - type - ) - ); - } - // Operation header params are explicit, typed inputs; security-scheme headers - // are injected by the runtime and live underneath them. - if (op.headerParams.length > 0) - args.push(renderParamsObjectArg('headers', op.headerParams, dateType)); - if (op.cookieParams.length > 0) - args.push(renderParamsObjectArg('cookies', op.cookieParams, dateType)); - // SSE ops take per-stream `SseOptions` (reconnect knobs); everyone else the - // standard per-call `RequestOptions`. - args.push( - simpleParam( - 'init', - factory.createTypeReferenceNode(isSseOp(op) ? 'SseOptions' : 'RequestOptions'), - true - ) - ); - return args; -} diff --git a/packages/client-generator/src/emitters/pagination.ts b/packages/client-generator/src/emitters/pagination.ts index 41ecc8553c..6d3e96c798 100644 --- a/packages/client-generator/src/emitters/pagination.ts +++ b/packages/client-generator/src/emitters/pagination.ts @@ -1,4 +1,4 @@ -// Auto-pagination resolution: turns config rules and `x-redocly-pagination` extensions into the +// Auto-pagination resolution: turns config rules and `x-redoclyPagination` extensions into the // normalized descriptor `PaginationSpec`, statically VERIFYING each rule fits its // operation (the advance param is a declared query param whose schema fits the style — // string-ish for `cursor`, a numeric scalar for `offset`/`page`; the JSON pointers @@ -8,6 +8,7 @@ import { isPlainObject, logger } from '@redocly/openapi-core'; +import { schemaAtPointer as resolveSchemaPointer } from '../authoring/schema.js'; import { allOperations, type ApiModel, @@ -21,7 +22,7 @@ import { isSseOp } from './sse.js'; export type PaginationStyle = 'cursor' | 'offset' | 'page' | 'link'; /** - * One user-facing pagination rule — the shared shape of the `x-redocly-pagination` operation + * One user-facing pagination rule — the shared shape of the `x-redoclyPagination` operation * extension and every `pagination` config rule. `nextCursor` and `items` are RFC 6901 * JSON pointers (starting with `/`) into the operation's success response. */ @@ -51,12 +52,12 @@ export type PaginationRule = { * The `pagination` config block: an optional convention rule (the top-level rule * fields, applied to every operation it structurally fits when `style` is set), plus * per-operation overrides and exclusions. Precedence per operation: - * `operations[id]` > the spec's `x-redocly-pagination` extension > the convention rule. + * `operations[id]` > the spec's `x-redoclyPagination` extension > the convention rule. */ export type PaginationConfig = Partial & { /** operationIds no source may paginate. */ exclude?: string[]; - /** Per-operation rules, keyed by operationId (beat `x-redocly-pagination` and the convention). */ + /** Per-operation rules, keyed by operationId (beat `x-redoclyPagination` and the convention). */ operations?: Record; }; @@ -72,7 +73,7 @@ export type ModelPagination = Map - * `x-redocly-pagination` > convention); `config.exclude` kills all of them. Returns the + * `x-redoclyPagination` > convention); `config.exclude` kills all of them. Returns the * normalized spec + the item element schema, `{}` when the operation doesn't paginate * (no source, or a convention that doesn't fit), or an `error` for a malformed rule * (any source) and for an explicit rule that doesn't fit the operation. @@ -89,7 +90,7 @@ export function resolveOperationPagination( return applyRule(op, model, perOp, `pagination.operations["${configName}"]`, true); } if (op.paginationExtension !== undefined) { - return applyRule(op, model, op.paginationExtension, 'x-redocly-pagination', true); + return applyRule(op, model, op.paginationExtension, 'x-redoclyPagination', true); } if (config?.style !== undefined) { const { exclude: _exclude, operations: _operations, ...convention } = config; @@ -145,7 +146,13 @@ function applyRule( const param = valid.style === 'cursor' ? valid.cursorParam! : valid.offsetParam!; const advance = op.queryParams.find((p) => p.name === param); if (!advance) { - return misfit(`query parameter "${param}" is not declared on the operation`); + // Name what IS declared: the fix is almost always a different spelling + // (`after` vs `cursor`), and the message should make that obvious. + const declared = op.queryParams.map((p) => p.name).join(', '); + return misfit( + `query parameter "${param}" is not declared on the operation` + + (declared === '' ? '' : ` (declared: ${declared})`) + ); } // The advance param must accept what the runtime sends: the response's cursor // (string-ish, same predicate as nextCursor) or the incremented number. @@ -271,52 +278,8 @@ function ruleShapeProblem(rule: unknown): string | undefined { return undefined; } -/** - * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) over a schema, walking the - * VALUE shape it describes: object property steps by name, record values for any token, - * array items for numeric tokens, with `ref` steps resolved through the model's named - * schemas (cycle-guarded). Intersections (`allOf` — the common collection-base pattern) - * resolve across their members; unions bail (genuinely ambiguous — v1 is strict). - * Returns `undefined` on any miss — the caller decides whether that is an error. - */ -export function resolveSchemaPointer( - schema: SchemaModel, - pointer: string, - model: ApiModel -): SchemaModel | undefined { - let current = deref(schema, model); - if (current === undefined || (pointer !== '' && !pointer.startsWith('/'))) return undefined; - if (pointer === '') return current; - for (const token of pointer.slice(1).split('/')) { - const key = token.replaceAll('~1', '/').replaceAll('~0', '~'); - const next = stepIntoSchema(current, key, model); - if (next === undefined) return undefined; - current = deref(next, model); - if (current === undefined) return undefined; - } - return current; -} - -/** One pointer step over a (dereferenced) schema; an intersection takes the LAST member that resolves, since later `allOf` members refine earlier ones. */ -function stepIntoSchema( - schema: SchemaModel, - key: string, - model: ApiModel -): SchemaModel | undefined { - if (schema.kind === 'object') return schema.properties.find((p) => p.name === key)?.schema; - if (schema.kind === 'record') return schema.value; - if (schema.kind === 'array' && /^(0|[1-9]\d*)$/.test(key)) return schema.items; - if (schema.kind === 'intersection') { - let match: SchemaModel | undefined; - for (const member of schema.members) { - const target = deref(member, model); - if (target === undefined) continue; - match = stepIntoSchema(target, key, model) ?? match; - } - return match; - } - return undefined; -} +/** The neutral RFC 6901 schema walker, re-exported under its original name here. */ +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/emitters/php-runtime-sources.ts b/packages/client-generator/src/emitters/php-runtime-sources.ts new file mode 100644 index 0000000000..f354dc66da --- /dev/null +++ b/packages/client-generator/src/emitters/php-runtime-sources.ts @@ -0,0 +1,3 @@ +// GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. +export const PHP_RUNTIME_SOURCE = + "= 8.1, zero Composer dependencies; HTTP over the curl extension.\n// The generated file re-declares the namespace; the embed strips this header.\n\ndeclare(strict_types=1);\n\nnamespace RedoclyClientRuntime;\n\n/** A response with status >= 400, decoded body attached. */\nfinal class ApiError extends \\RuntimeException\n{\n public function __construct(\n public readonly string $url,\n public readonly int $status,\n public readonly string $reason,\n public readonly mixed $body,\n ) {\n parent::__construct(\"HTTP {$status} {$reason} for {$url}\");\n }\n}\n\n/** Every attempt timed out or failed to connect. */\nfinal class TimeoutError extends \\RuntimeException\n{\n public function __construct(\n public readonly string $url,\n public readonly ?float $timeout,\n public readonly int $attempts,\n ) {\n $seconds = $timeout === null ? 'the configured timeout' : \"{$timeout}s\";\n parent::__construct(\"Request to {$url} timed out after {$seconds} ({$attempts} attempt(s))\");\n }\n}\n\n/** One parsed `text/event-stream` frame. */\n/** A `WithHeaders()` result: the decoded body plus coerced declared headers. */\nfinal class Envelope\n{\n public function __construct(\n public readonly mixed $data,\n public readonly array $headers,\n public readonly int $status,\n ) {\n }\n}\n\n/** Coerce declared response headers per `[name, key, type]` specs; absent/unparsable omitted. */\nfunction readEnvelopeHeaders(array $response, array $specs): array\n{\n $headers = [];\n foreach ($specs as [$name, $key, $type]) {\n $raw = $response['headers'][$name] ?? null;\n if ($raw === null) {\n continue;\n }\n if ($type === 'integer' || $type === 'number') {\n if (is_numeric($raw)) {\n $headers[$key] = $type === 'integer' ? (int) $raw : (float) $raw;\n }\n } elseif ($type === 'boolean') {\n $lower = strtolower(trim($raw));\n if ($lower === 'true' || $lower === 'false') {\n $headers[$key] = $lower === 'true';\n }\n } else {\n $headers[$key] = $raw;\n }\n }\n return $headers;\n}\n\nfinal class ServerSentEvent\n{\n public function __construct(\n public readonly string $event,\n public readonly mixed $data,\n public readonly ?string $id = null,\n public readonly ?int $retry = null,\n ) {\n }\n}\n\n/**\n * Per-instance configuration.\n * `auth`: `['bearer' => string|callable, 'basic' => ['username' => ..., 'password' => ...], 'apiKey' => [scheme => string|callable]]`.\n * `retry`: `['attempts' => int, 'delay' => float, 'strategy' => 'exponential'|'fixed', 'retryOn' => callable]`.\n * `middleware`: callables `fn(array $request, callable $next): array` around each attempt.\n */\nfinal class Config\n{\n public function __construct(\n public string $serverUrl = '',\n public array $auth = [],\n public ?float $timeout = null,\n public array $retry = [],\n public array $middleware = [],\n public string $clientHeader = 'redocly-client-generator',\n ) {\n }\n}\n\n/** Resolve a literal-or-callable credential to its string value. */\nfunction resolveToken(mixed $provider): string\n{\n return is_callable($provider) ? (string) $provider() : (string) $provider;\n}\n\n/**\n * Apply the first fully-configured security alternative. `$security` is an OR-list\n * of AND-sets of specs: `['kind' => 'bearer'|'basic'|'apiKey', 'scheme' => ..., 'name' => ?, 'in' => ?]`.\n * Returns `[headers, query, cookies]`.\n */\nfunction resolveAuth(array $security, array $auth): array\n{\n foreach ($security as $andSet) {\n $headers = [];\n $query = [];\n $cookies = [];\n $satisfied = true;\n foreach ($andSet as $spec) {\n if ($spec['kind'] === 'bearer' && isset($auth['bearer'])) {\n $headers['Authorization'] = 'Bearer ' . resolveToken($auth['bearer']);\n } elseif ($spec['kind'] === 'basic' && isset($auth['basic'])) {\n $headers['Authorization'] =\n 'Basic ' . base64_encode($auth['basic']['username'] . ':' . $auth['basic']['password']);\n } elseif ($spec['kind'] === 'apiKey' && isset($auth['apiKey'][$spec['scheme']])) {\n $value = resolveToken($auth['apiKey'][$spec['scheme']]);\n if ($spec['in'] === 'query') {\n $query[$spec['name']] = $value;\n } elseif ($spec['in'] === 'cookie') {\n $cookies[] = $spec['name'] . '=' . rawurlencode($value);\n } else {\n $headers[$spec['name']] = $value;\n }\n } else {\n $satisfied = false;\n break;\n }\n }\n if ($satisfied) {\n return [$headers, $query, $cookies];\n }\n }\n return [[], [], []];\n}\n\n/** Substitute `{param}` templates with encoded values and prefix the server URL. */\nfunction buildUrl(string $serverUrl, string $path, array $pathParams): string\n{\n foreach ($pathParams as $name => $value) {\n $path = str_replace('{' . $name . '}', rawurlencode((string) $value), $path);\n }\n return rtrim($serverUrl, '/') . $path;\n}\n\n/** The default retry predicate: 5xx, 429, and transport timeouts/connect failures. */\nfunction defaultRetryOn(array $context): bool\n{\n if (($context['timedOut'] ?? false) === true) {\n return true;\n }\n $status = $context['status'] ?? 0;\n return $status >= 500 || $status === 429;\n}\n\n/** Delay before the next attempt: `Retry-After` wins; otherwise jittered (fixed|exponential) backoff. */\nfunction retryDelay(int $attempt, array $retry, ?string $retryAfter): float\n{\n if ($retryAfter !== null && ctype_digit($retryAfter)) {\n return (float) $retryAfter;\n }\n $base = (float) ($retry['delay'] ?? 1.0);\n $strategy = $retry['strategy'] ?? 'exponential';\n $delay = $strategy === 'fixed' ? $base : $base * (2 ** ($attempt - 1));\n return $delay * (0.5 + mt_rand() / mt_getrandmax() / 2);\n}\n\n/** Append query params in form style: list values repeat the key (`tag=a&tag=b`). */\nfunction appendQuery(string $url, array $query): string\n{\n $pairs = [];\n foreach ($query as $name => $value) {\n foreach (is_array($value) ? $value : [$value] as $single) {\n $encoded = is_bool($single) ? ($single ? 'true' : 'false') : (string) $single;\n $pairs[] = rawurlencode($name) . '=' . rawurlencode($encoded);\n }\n }\n if ($pairs === []) {\n return $url;\n }\n return $url . (str_contains($url, '?') ? '&' : '?') . implode('&', $pairs);\n}\n\n/** One raw curl exchange. Returns `['status', 'reason', 'headers', 'body', 'url', 'timedOut']`. */\nfunction rawSend(Config $config, array $request): array\n{\n $url = appendQuery($request['url'], $request['query'] ?? []);\n $handle = curl_init($url);\n $headerLines = [];\n foreach ($request['headers'] ?? [] as $name => $value) {\n $headerLines[] = $name . ': ' . $value;\n }\n $responseHeaders = [];\n curl_setopt_array($handle, [\n CURLOPT_CUSTOMREQUEST => $request['method'],\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_HTTPHEADER => $headerLines,\n CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$responseHeaders): int {\n $parts = explode(':', $line, 2);\n if (count($parts) === 2) {\n $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);\n }\n return strlen($line);\n },\n ]);\n if (($request['body'] ?? null) !== null) {\n curl_setopt($handle, CURLOPT_POSTFIELDS, $request['body']);\n }\n if ($config->timeout !== null) {\n curl_setopt($handle, CURLOPT_TIMEOUT_MS, (int) round($config->timeout * 1000));\n }\n $body = curl_exec($handle);\n $errno = curl_errno($handle);\n $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);\n $effectiveUrl = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL);\n if ($errno !== 0) {\n $timedOut = $errno === CURLE_OPERATION_TIMEDOUT || $errno === CURLE_COULDNT_CONNECT;\n return [\n 'status' => 0,\n 'reason' => curl_strerror($errno) ?? 'transport error',\n 'headers' => [],\n 'body' => '',\n 'url' => $effectiveUrl,\n 'timedOut' => $timedOut,\n ];\n }\n return [\n 'status' => $status,\n 'reason' => '',\n 'headers' => $responseHeaders,\n 'body' => is_string($body) ? $body : '',\n 'url' => $effectiveUrl,\n 'timedOut' => false,\n ];\n}\n\n/**\n * Send with retries and middleware. `$request` carries `operationId`, `method`, `url`,\n * `headers`, `query`, and optional `body`/`contentType`/`idempotencyKey`.\n * Returns the raw response array; callers map status >= 400 to `ApiError`.\n */\nfunction send(Config $config, array $request): array\n{\n $headers = $request['headers'] ?? [];\n $headers['X-Redocly-Client'] = $config->clientHeader;\n if (($request['contentType'] ?? null) !== null) {\n $headers['Content-Type'] = $request['contentType'];\n }\n if (($request['idempotencyKey'] ?? null) !== null) {\n $headers['Idempotency-Key'] = $request['idempotencyKey'];\n }\n $request['headers'] = $headers;\n\n $handler = fn (array $req): array => rawSend($config, $req);\n foreach (array_reverse($config->middleware) as $middleware) {\n $next = $handler;\n $handler = fn (array $req): array => $middleware($req, $next);\n }\n\n $attempts = max(1, (int) ($config->retry['attempts'] ?? 3));\n $retryOn = $config->retry['retryOn'] ?? __NAMESPACE__ . '\\\\defaultRetryOn';\n $response = null;\n for ($attempt = 1; $attempt <= $attempts; $attempt++) {\n $response = $handler($request);\n $context = [\n 'status' => $response['status'],\n 'timedOut' => $response['timedOut'],\n 'attempt' => $attempt,\n 'operationId' => $request['operationId'] ?? '',\n ];\n if ($attempt === $attempts || !$retryOn($context)) {\n break;\n }\n $seconds = retryDelay($attempt, $config->retry, $response['headers']['retry-after'] ?? null);\n usleep((int) round($seconds * 1_000_000));\n }\n if ($response['timedOut']) {\n throw new TimeoutError($response['url'], $config->timeout, $attempts);\n }\n if ($response['status'] === 0) {\n throw new \\RuntimeException(\"Request to {$response['url']} failed: {$response['reason']}\");\n }\n return $response;\n}\n\n/** Decoded JSON body (assoc arrays), or null for empty bodies. */\nfunction decodeJson(array $response): mixed\n{\n if ($response['body'] === '') {\n return null;\n }\n return json_decode($response['body'], true);\n}\n\n/** `ApiError` from a non-2xx response. */\nfunction apiErrorFrom(array $response): ApiError\n{\n return new ApiError($response['url'], $response['status'], $response['reason'], decodeJson($response));\n}\n\n/** Walk an RFC 6901 JSON pointer over decoded JSON; null on any miss. */\nfunction resolvePointer(mixed $data, string $pointer): mixed\n{\n if ($pointer === '') {\n return $data;\n }\n foreach (explode('/', substr($pointer, 1)) as $token) {\n $key = str_replace(['~1', '~0'], ['/', '~'], $token);\n if (!is_array($data) || !array_key_exists($key, $data)) {\n return null;\n }\n $data = $data[$key];\n }\n return $data;\n}\n\n/** The `rel=\"next\"` target of a `Link` header, or null. */\nfunction linkNext(?string $header): ?string\n{\n if ($header === null) {\n return null;\n }\n foreach (explode(',', $header) as $part) {\n if (preg_match('/<([^>]+)>\\s*;[^,]*rel=\"?next\"?/', trim($part), $match) === 1) {\n return $match[1];\n }\n }\n return null;\n}\n\n/**\n * Auto-pagination: `$call(array $params): [mixed rawPage, array $response]`, `$spec` is the\n * normalized rule (`style`, `param`, `nextCursor`, `hasMore`, `items`), `$base` the caller's\n * query params. Yields raw decoded pages; generated wrappers hydrate them into models.\n */\nfunction iterPages(callable $call, array $spec, array $base): \\Generator\n{\n $params = $base;\n $style = $spec['style'];\n $seenCursors = [];\n $seenLinks = [];\n $offset = null;\n $page = null;\n while (true) {\n [$raw, $response] = $call($params);\n yield $raw;\n if ($style === 'cursor') {\n $next = resolvePointer($raw, $spec['nextCursor'] ?? '');\n if (isset($spec['hasMore']) && resolvePointer($raw, $spec['hasMore']) !== true) {\n return;\n }\n if (!is_string($next) || $next === '' || isset($seenCursors[$next])) {\n return;\n }\n $seenCursors[$next] = true;\n $params[$spec['param']] = $next;\n } elseif ($style === 'link') {\n $target = linkNext($response['headers']['link'] ?? null);\n if ($target === null || isset($seenLinks[$target])) {\n return;\n }\n $seenLinks[$target] = true;\n $parsed = parse_url($target);\n $linkParams = [];\n parse_str($parsed['query'] ?? '', $linkParams);\n $params = array_merge($params, $linkParams);\n } else {\n $items = resolvePointer($raw, $spec['items'] ?? '');\n $count = is_array($items) ? count($items) : 0;\n if ($count === 0) {\n return;\n }\n if ($style === 'offset') {\n $offset = ($offset ?? (int) ($base[$spec['param']] ?? 0)) + $count;\n $params[$spec['param']] = $offset;\n } else {\n $page = ($page ?? (int) ($base[$spec['param']] ?? 1)) + 1;\n $params[$spec['param']] = $page;\n }\n }\n }\n}\n\n/** Parse one SSE frame; returns `[?ServerSentEvent, ?string lastEventId, ?int retryMs]`. */\nfunction parseSseFrame(string $frame, bool $jsonData): array\n{\n $event = 'message';\n $dataLines = [];\n $id = null;\n $retry = null;\n foreach (explode(\"\\n\", str_replace(\"\\r\\n\", \"\\n\", $frame)) as $line) {\n if ($line === '' || str_starts_with($line, ':')) {\n continue;\n }\n $colon = strpos($line, ':');\n $field = $colon === false ? $line : substr($line, 0, $colon);\n $value = $colon === false ? '' : ltrim(substr($line, $colon + 1), ' ');\n if ($field === 'event') {\n $event = $value;\n } elseif ($field === 'data') {\n $dataLines[] = $value;\n } elseif ($field === 'id') {\n $id = $value;\n } elseif ($field === 'retry' && ctype_digit($value)) {\n $retry = (int) $value;\n }\n }\n if ($dataLines === [] && $id === null && $retry === null) {\n return [null, null, $retry];\n }\n $data = implode(\"\\n\", $dataLines);\n $decoded = $jsonData && $data !== '' ? json_decode($data, true) : $data;\n return [new ServerSentEvent($event, $decoded, $id, $retry), $id, $retry];\n}\n\n/**\n * Stream server-sent events. `$open(array $extraHeaders): \\CurlHandle` returns a configured\n * (not yet executed) handle; this pump drives it with curl_multi, yields parsed frames, and\n * reconnects with `Last-Event-ID` on transient failures (4xx is definitive; backoff <= 30s).\n */\nfunction iterSse(callable $open, bool $jsonData): \\Generator\n{\n $lastEventId = null;\n $retryMs = 3000;\n while (true) {\n $extra = ['Accept' => 'text/event-stream'];\n if ($lastEventId !== null) {\n $extra['Last-Event-ID'] = $lastEventId;\n }\n $handle = $open($extra);\n $buffer = '';\n curl_setopt($handle, CURLOPT_WRITEFUNCTION, function ($ch, string $chunk) use (&$buffer): int {\n $buffer .= $chunk;\n return strlen($chunk);\n });\n $multi = curl_multi_init();\n curl_multi_add_handle($multi, $handle);\n do {\n curl_multi_exec($multi, $running);\n if ($running > 0) {\n curl_multi_select($multi, 0.1);\n }\n while (($split = strpos($buffer, \"\\n\\n\")) !== false || ($split = strpos($buffer, \"\\r\\n\\r\\n\")) !== false) {\n $frameLength = $buffer[$split] === \"\\r\" ? 4 : 2;\n $frame = substr($buffer, 0, $split);\n $buffer = substr($buffer, $split + $frameLength);\n [$event, $id, $retry] = parseSseFrame($frame, $jsonData);\n if ($id !== null) {\n $lastEventId = $id;\n }\n if ($retry !== null) {\n $retryMs = min($retry, 30000);\n }\n if ($event !== null) {\n yield $event;\n }\n }\n } while ($running > 0);\n $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);\n $url = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL);\n curl_multi_remove_handle($multi, $handle);\n curl_multi_close($multi);\n if ($status >= 400 && $status < 500) {\n throw new ApiError($url, $status, '', $buffer);\n }\n // A clean 200 end-of-stream is done; anything else reconnects with Last-Event-ID.\n if ($status === 200) {\n return;\n }\n usleep($retryMs * 1000);\n }\n}\n\n/** Encode an assoc body as `multipart/form-data`; nested values are JSON parts. Returns `[contentType, body]`. */\nfunction toMultipart(array $body): array\n{\n $boundary = 'redocly-' . bin2hex(random_bytes(12));\n $parts = '';\n foreach ($body as $name => $value) {\n $parts .= \"--{$boundary}\\r\\n\";\n if (is_array($value)) {\n $parts .= \"Content-Disposition: form-data; name=\\\"{$name}\\\"\\r\\n\";\n $parts .= \"Content-Type: application/json\\r\\n\\r\\n\";\n $parts .= json_encode($value) . \"\\r\\n\";\n } else {\n $parts .= \"Content-Disposition: form-data; name=\\\"{$name}\\\"\\r\\n\\r\\n\";\n $parts .= (is_bool($value) ? ($value ? 'true' : 'false') : (string) $value) . \"\\r\\n\";\n }\n }\n $parts .= \"--{$boundary}--\\r\\n\";\n return ['multipart/form-data; boundary=' . $boundary, $parts];\n}\n"; diff --git a/packages/client-generator/src/emitters/python-runtime-sources.ts b/packages/client-generator/src/emitters/python-runtime-sources.ts new file mode 100644 index 0000000000..905177aa74 --- /dev/null +++ b/packages/client-generator/src/emitters/python-runtime-sources.ts @@ -0,0 +1,21 @@ +// GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. +export const PYTHON_RUNTIME_SOURCES = { + '_errors.py': + '# Runtime errors and the result-mode envelope for generated Python clients.\n# Hand-authored once, embedded into every generated client (see\n# scripts/generate-runtime-sources.mjs) — mirror of the TypeScript runtime\'s\n# errors.ts, kept semantically in lockstep.\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom typing import Any, Generic, Optional, TypeVar\n\nT = TypeVar("T")\nE = TypeVar("E")\n\n\nclass ApiError(Exception):\n """Raised (throw mode) for a non-2xx response, carrying the decoded error body."""\n\n def __init__(self, url: str, status: int, status_text: str, body: Any) -> None:\n super().__init__(f"Request failed with status {status}")\n self.url = url\n self.status = status\n self.status_text = status_text\n self.body = body\n\n\nclass ApiTimeoutError(Exception):\n """Raised when a request attempt exceeds the configured timeout — carries the\n context a log line needs (which operation, what budget, which attempt)."""\n\n def __init__(self, operation_id: str, timeout: float, attempt: int) -> None:\n super().__init__(\n f\'Request "{operation_id}" timed out after {timeout} s (attempt {attempt})\'\n )\n self.operation_id = operation_id\n self.timeout = timeout\n self.attempt = attempt\n\n\n@dataclass\nclass Result(Generic[T, E]):\n """Result-mode return shape: exactly one of `data`/`error` is set."""\n\n data: Optional[T]\n error: Optional[E]\n response: Any # httpx.Response\n\n @property\n def ok(self) -> bool:\n return self.error is None\n', + '_auth.py': + '# Auth resolution for generated Python clients — mirror of the TypeScript\n# runtime\'s auth.ts: the first OR-alternative whose schemes are all configured\n# is applied, so "bearer OR apiKey" works with either credential and never\n# sends both. Cookie-borne api keys fold into a single Cookie header.\nfrom __future__ import annotations\n\nimport base64\nfrom typing import Any, Callable, Dict, List, Tuple, Union\nfrom urllib.parse import quote\n\nTokenProvider = Union[str, Callable[[], str]]\n\n\ndef _api_keys(auth: Dict[str, Any]) -> Dict[str, Any]:\n """The apiKey credentials. `apiKey` is the documented key (it matches the scheme\n kind and the other language SDKs); `api_key` is accepted too, so a snake_case\n config keeps working."""\n return {**(auth.get("api_key") or {}), **(auth.get("apiKey") or {})}\n\ndef _resolve_token(provider: TokenProvider) -> str:\n return provider() if callable(provider) else provider\n\n\ndef _is_configured(scheme: Dict[str, Any], auth: Dict[str, Any]) -> bool:\n kind = scheme["kind"]\n if kind == "apiKey":\n return scheme["scheme"] in _api_keys(auth)\n if kind == "bearer":\n return auth.get("bearer") is not None\n return auth.get("basic") is not None\n\n\ndef resolve_auth(\n security: List[List[Dict[str, Any]]], auth: Dict[str, Any]\n) -> Tuple[Dict[str, str], Dict[str, str]]:\n """Build (headers, query) for one operation\'s security OR-alternatives from\n the client credentials. When no alternative is fully configured, the first\n alternative\'s configured schemes are still sent (the server rejects the\n request — same behavior as the TypeScript runtime)."""\n alternative = next(\n (schemes for schemes in security if all(_is_configured(s, auth) for s in schemes)),\n security[0] if security else [],\n )\n headers: Dict[str, str] = {}\n query: Dict[str, str] = {}\n cookies: List[str] = []\n for scheme in alternative:\n kind = scheme["kind"]\n if kind == "apiKey":\n provider = _api_keys(auth).get(scheme["scheme"])\n if provider is None:\n continue\n value = _resolve_token(provider)\n location = scheme.get("in", "header")\n if location == "header":\n headers[scheme["name"]] = value\n elif location == "query":\n query[scheme["name"]] = value\n else:\n # Reserved characters (`;`, `=`, space) must not break Cookie syntax.\n cookies.append(f"{scheme[\'name\']}={quote(value, safe=\'\')}")\n elif kind == "bearer":\n provider = auth.get("bearer")\n if provider is not None:\n headers["Authorization"] = f"Bearer {_resolve_token(provider)}"\n else:\n basic = auth.get("basic")\n if basic is not None:\n username, password = basic["username"], basic["password"]\n token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")\n headers["Authorization"] = f"Basic {token}"\n if cookies:\n headers["Cookie"] = "; ".join(cookies)\n return headers, query\n', + '_url.py': + '# URL assembly for generated Python clients — path-parameter substitution with\n# percent-encoding, mirroring the TypeScript runtime\'s url.ts semantics.\nfrom __future__ import annotations\n\nfrom typing import Any, Dict\nfrom urllib.parse import quote\n\n\ndef build_url(server_url: str, path: str, path_params: Dict[str, Any]) -> str:\n filled = path\n for name, value in path_params.items():\n filled = filled.replace("{" + name + "}", quote(str(value), safe=""))\n return server_url.rstrip("/") + filled\n', + '_decode.py': + '# Reflective JSON <-> model conversion for generated Python clients. Models are\n# plain dataclasses by default, or pydantic BaseModels under `models: pydantic`;\n# one decoder serves both. For a dataclass it hydrates parsed JSON reflectively,\n# honoring each class\'s `_field_map` (python name -> wire name) and the typing\n# constructs the generator emits: Optional/Union, List, Dict, Enum, Literal, Any.\n# For a pydantic model it defers to pydantic, which already knows the aliases.\n# encode() mirrors whichever it was given back to wire shape.\nfrom __future__ import annotations\n\nimport dataclasses\nimport typing\nfrom datetime import date, datetime\nfrom enum import Enum\nfrom typing import Any, Dict, Tuple, get_args, get_origin, get_type_hints\n\n# Discriminated unions: resolved Union annotation -> (wire property, {value: class}).\n# The generated module registers its unions here; decode() dispatches through it\n# before falling back to trying members in order.\nDISCRIMINATORS: Dict[Any, Tuple[str, Dict[str, Any]]] = {}\n\n\ndef decode(type_: Any, data: Any):\n """Best-effort hydration: wire data -> the annotated Python shape. Unknown or\n mismatched shapes pass through unchanged (the server is the source of truth)."""\n if data is None or type_ is Any or type_ is None:\n return data\n # `Annotated[Union[...], Field(discriminator=...)]`: pydantic reads that annotation on a\n # model\'s own field, so here only the union underneath matters.\n if hasattr(type_, "__metadata__"):\n type_ = get_args(type_)[0]\n origin = get_origin(type_)\n if origin is typing.Union:\n discriminator = DISCRIMINATORS.get(type_)\n if discriminator is not None and isinstance(data, dict):\n wire_property, mapping = discriminator\n target = mapping.get(data.get(wire_property))\n if target is not None:\n try:\n return decode(target, data)\n except (TypeError, ValueError, KeyError):\n pass\n for member in get_args(type_):\n if member is type(None):\n continue\n try:\n return decode(member, data)\n except (TypeError, ValueError, KeyError):\n continue\n return data\n if origin is list:\n (item_type,) = get_args(type_) or (Any,)\n return [decode(item_type, item) for item in data]\n if origin is dict:\n args = get_args(type_)\n value_type = args[1] if len(args) == 2 else Any\n return {key: decode(value_type, value) for key, value in data.items()}\n if origin is typing.Literal:\n return data\n if isinstance(type_, type) and issubclass(type_, Enum):\n return type_(data)\n # `dateType: Date` annotates date/date-time fields as datetime objects; a value that\n # doesn\'t parse passes through unchanged (the server is the source of truth).\n if type_ is datetime or type_ is date:\n if not isinstance(data, str):\n return data\n try:\n # `datetime` accepts a bare date too; `date` rejects a timestamp, so trim it.\n return (\n datetime.fromisoformat(data)\n if type_ is datetime\n else date.fromisoformat(data[:10])\n )\n except ValueError:\n return data\n # A pydantic model validates itself, aliases included. `ValidationError`\n # subclasses `ValueError`, so union member probing above still works.\n if isinstance(type_, type) and hasattr(type_, "model_validate"):\n return type_.model_validate(data)\n if dataclasses.is_dataclass(type_):\n hints = get_type_hints(type_)\n field_map = getattr(type_, "_field_map", {})\n kwargs = {}\n for field in dataclasses.fields(type_):\n wire = field_map.get(field.name, field.name)\n if isinstance(data, dict) and wire in data:\n kwargs[field.name] = decode(hints.get(field.name, Any), data[wire])\n return type_(**kwargs)\n return data\n\n\ndef encode(value: Any):\n """Python shape -> wire (JSON) shape; inverse of decode for request bodies."""\n # `mode="json"` resolves datetimes and enums the same way the branches below do,\n # and `exclude_none` matches the dataclass path: an unset optional is not sent.\n if hasattr(value, "model_dump") and not isinstance(value, type):\n return value.model_dump(by_alias=True, exclude_none=True, mode="json")\n if dataclasses.is_dataclass(value) and not isinstance(value, type):\n field_map = getattr(type(value), "_field_map", {})\n out = {}\n for field in dataclasses.fields(value):\n item = getattr(value, field.name)\n if item is None:\n continue\n out[field_map.get(field.name, field.name)] = encode(item)\n return out\n if isinstance(value, Enum):\n return value.value\n # A date-only value must not gain a time component on the way out.\n if isinstance(value, datetime):\n return value.isoformat()\n if isinstance(value, date):\n return value.isoformat()\n if isinstance(value, list):\n return [encode(item) for item in value]\n if isinstance(value, dict):\n return {key: encode(item) for key, item in value.items()}\n return value\n', + '_send.py': + '# The request core for generated Python clients — mirror of the TypeScript\n# runtime\'s send.ts: default + config + per-call headers, on_request middleware\n# BEFORE serialization (mutations are sent), the retry loop (idempotent-methods\n# default, Idempotency-Key opt-in makes POST/PATCH safe, Retry-After honored,\n# exponential backoff with full jitter, a fresh timeout budget per attempt), and\n# the reverse on_response onion.\nfrom __future__ import annotations\n\nimport asyncio\nimport random\nimport time\nimport uuid\nfrom dataclasses import dataclass\nfrom typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar\n\nimport httpx\n\nfrom ._errors import ApiTimeoutError\n\nT = TypeVar("T")\n\n\n@dataclass\nclass Envelope(Generic[T]):\n """A *_with_headers() result: decoded body + coerced declared headers + raw response."""\n\n data: T\n headers: Dict[str, Any]\n response: httpx.Response\n\n\ndef read_envelope_headers(\n response: httpx.Response, specs: List[Tuple[str, str, str]]\n) -> Dict[str, Any]:\n """Coerce declared response headers per (name, key, type) specs; absent/unparsable omitted."""\n headers: Dict[str, Any] = {}\n for name, key, type_ in specs:\n raw = response.headers.get(name)\n if raw is None:\n continue\n if type_ in ("integer", "number"):\n try:\n headers[key] = int(raw) if type_ == "integer" else float(raw)\n except ValueError:\n pass\n elif type_ == "boolean":\n lower = raw.strip().lower()\n if lower in ("true", "false"):\n headers[key] = lower == "true"\n else:\n headers[key] = raw\n return headers\n\n\n_IDEMPOTENT_METHODS = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}\n_TRANSIENT_STATUS = {408, 429, 500, 502, 503, 504}\n\n\ndef _default_retry_on(method: str, headers: Dict[str, str], response: Optional[httpx.Response]) -> bool:\n safe = method.upper() in _IDEMPOTENT_METHODS or "Idempotency-Key" in headers\n if not safe:\n return False\n return response is None or response.status_code in _TRANSIENT_STATUS\n\n\ndef _retry_delay(retry: Dict[str, Any], attempt: int, retry_after: Optional[str]) -> float:\n if retry_after:\n try:\n return float(retry_after)\n except ValueError:\n pass # HTTP-date form: fall through to backoff\n base = float(retry.get("retry_delay", 1.0))\n raw = base if retry.get("retry_strategy") == "fixed" else base * (2 ** (attempt - 1))\n return random.uniform(0, raw) if retry.get("jitter", True) is not False else raw\n\n\ndef send(\n client: httpx.Client,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n data: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n\n # One stable key per LOGICAL call — set before the retry loop so every\n # attempt re-sends the same key; a caller-provided header always wins.\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n\n attempt = 0\n while True:\n attempt += 1\n try:\n response = client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None and data is None else None,\n content=content,\n data=data,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n time.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n\n\nasync def send_async(\n client: httpx.AsyncClient,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n data: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n """The async mirror of send() — same retry/timeout/idempotency semantics."""\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n attempt = 0\n while True:\n attempt += 1\n try:\n response = await client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None and data is None else None,\n content=content,\n data=data,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n', + '_paginate.py': + '# Auto-pagination iterators for generated Python clients — the TypeScript\n# runtime\'s paginate.ts semantics ported: cursor (next-cursor pointer, optional\n# has-more flag, repeated-cursor guard), offset/page (advance by count/one,\n# repeated-page guard, null start treated as absent), and link (RFC 8288\n# `Link: rel="next"` following with relative resolution and a loop guard).\nfrom __future__ import annotations\n\nimport re\nfrom typing import Any, AsyncIterator, Awaitable, Callable, Dict, Iterator, Optional, Tuple\nfrom urllib.parse import parse_qsl, urljoin, urlparse\n\n# call(params) -> (parsed_json, httpx.Response)\nPageCall = Callable[[Dict[str, Any]], Tuple[Any, Any]]\n\n\ndef resolve_pointer(data: Any, pointer: str) -> Any:\n """RFC 6901 JSON pointer over parsed JSON; None on any miss."""\n if pointer == "":\n return data\n if not pointer.startswith("/"):\n return None\n current = data\n for token in pointer[1:].split("/"):\n key = token.replace("~1", "/").replace("~0", "~")\n if isinstance(current, dict):\n current = current.get(key)\n elif isinstance(current, list) and key.isdigit():\n index = int(key)\n current = current[index] if index < len(current) else None\n else:\n return None\n if current is None:\n return None\n return current\n\n\ndef iter_pages(call: PageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> Iterator[Any]:\n """Yield raw page JSON per the pagination spec; every page is yielded before\n the stop condition is evaluated, so the last page always arrives."""\n style = spec["style"]\n base = dict(params or {})\n if style == "cursor":\n cursor = base.get(spec["param"])\n while True:\n page_params = dict(base)\n if cursor is not None:\n page_params[spec["param"]] = cursor\n page, _response = call(page_params)\n yield page\n if spec.get("has_more") is not None and resolve_pointer(page, spec["has_more"]) is False:\n return\n nxt = resolve_pointer(page, spec.get("next_cursor", ""))\n if nxt is None or nxt == "":\n return\n if not isinstance(nxt, (str, int, float)):\n raise ValueError(f"Pagination cursor at {spec[\'next_cursor\']} is not a string or number")\n if nxt == cursor:\n raise ValueError("Pagination did not advance: the operation returned the same cursor twice")\n cursor = nxt\n elif style == "link":\n yield from _iter_pages_by_link(call, base)\n else: # offset / page\n start = base.get(spec["param"])\n fallback = 1 if style == "page" else 0\n try:\n position = fallback if start in (None, "") else int(start)\n except (TypeError, ValueError):\n position = fallback\n previous_items = None\n while True:\n page, _response = call({**base, spec["param"]: position})\n items = resolve_pointer(page, spec.get("items", ""))\n serialized = repr(items) if isinstance(items, list) else None\n if serialized is not None and serialized == previous_items:\n raise ValueError("Pagination did not advance: the operation returned the same page twice")\n yield page\n if not isinstance(items, list) or len(items) == 0:\n return\n previous_items = serialized\n position += 1 if style == "page" else len(items)\n\n\ndef _link_next(header: Optional[str]) -> Optional[str]:\n if not header:\n return None\n for entry in re.split(r",\\s*(?=<)", header):\n match = re.match(r"^\\s*<([^>]*)>(.*)$", entry)\n if not match:\n continue\n rel = re.search(r\';\\s*rel\\s*=\\s*"?([^";]+)"?\', match.group(2), re.IGNORECASE)\n if rel and "next" in rel.group(1).split():\n return match.group(1)\n return None\n\n\ndef _iter_pages_by_link(call: PageCall, base: Dict[str, Any]) -> Iterator[Any]:\n params = dict(base)\n previous = None\n while True:\n page, response = call(params)\n yield page\n target = _link_next(response.headers.get("link"))\n if target is None:\n return\n page_url = str(response.request.url) if response.request is not None else ""\n nxt = urljoin(page_url or "http://relative.invalid", target)\n if nxt in (previous, page_url):\n raise ValueError(\'Pagination did not advance: the Link rel="next" target repeats\')\n previous = nxt\n link_params: Dict[str, Any] = {}\n for key, value in parse_qsl(urlparse(nxt).query):\n if key in link_params:\n existing = link_params[key]\n link_params[key] = [*existing, value] if isinstance(existing, list) else [existing, value]\n else:\n link_params[key] = value\n params = {**base, **link_params}\n\n\ndef iter_items(call: PageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> Iterator[Any]:\n """Each page\'s `items` pointer, flattened."""\n for page in iter_pages(call, spec, params):\n items = resolve_pointer(page, spec.get("items", ""))\n if isinstance(items, list):\n yield from items\n\n\n# call(params) -> awaitable of (parsed_json, httpx.Response)\nAsyncPageCall = Callable[[Dict[str, Any]], Awaitable[Tuple[Any, Any]]]\n\n\nasync def aiter_pages(\n call: AsyncPageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None\n) -> AsyncIterator[Any]:\n """Async mirror of iter_pages — same stop conditions and guards."""\n style = spec["style"]\n base = dict(params or {})\n if style == "cursor":\n cursor = base.get(spec["param"])\n while True:\n page_params = dict(base)\n if cursor is not None:\n page_params[spec["param"]] = cursor\n page, _response = await call(page_params)\n yield page\n if spec.get("has_more") is not None and resolve_pointer(page, spec["has_more"]) is False:\n return\n nxt = resolve_pointer(page, spec.get("next_cursor", ""))\n if nxt is None or nxt == "":\n return\n if not isinstance(nxt, (str, int, float)):\n raise ValueError(f"Pagination cursor at {spec[\'next_cursor\']} is not a string or number")\n if nxt == cursor:\n raise ValueError("Pagination did not advance: the operation returned the same cursor twice")\n cursor = nxt\n elif style == "link":\n previous = None\n link_params: Dict[str, Any] = dict(base)\n while True:\n page, response = await call(link_params)\n yield page\n target = _link_next(response.headers.get("link"))\n if target is None:\n return\n page_url = str(response.request.url) if response.request is not None else ""\n nxt = urljoin(page_url or "http://relative.invalid", target)\n if nxt in (previous, page_url):\n raise ValueError(\'Pagination did not advance: the Link rel="next" target repeats\')\n previous = nxt\n merged: Dict[str, Any] = {}\n for key, value in parse_qsl(urlparse(nxt).query):\n if key in merged:\n existing = merged[key]\n merged[key] = [*existing, value] if isinstance(existing, list) else [existing, value]\n else:\n merged[key] = value\n link_params = {**base, **merged}\n else:\n start = base.get(spec["param"])\n fallback = 1 if style == "page" else 0\n try:\n position = fallback if start in (None, "") else int(start)\n except (TypeError, ValueError):\n position = fallback\n previous_items = None\n while True:\n page, _response = await call({**base, spec["param"]: position})\n items = resolve_pointer(page, spec.get("items", ""))\n serialized = repr(items) if isinstance(items, list) else None\n if serialized is not None and serialized == previous_items:\n raise ValueError("Pagination did not advance: the operation returned the same page twice")\n yield page\n if not isinstance(items, list) or len(items) == 0:\n return\n previous_items = serialized\n position += 1 if style == "page" else len(items)\n\n\nasync def aiter_items(\n call: AsyncPageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None\n) -> AsyncIterator[Any]:\n async for page in aiter_pages(call, spec, params):\n items = resolve_pointer(page, spec.get("items", ""))\n if isinstance(items, list):\n for item in items:\n yield item\n', + '_sse.py': + '# Server-Sent Events for generated Python clients — the TypeScript runtime\'s\n# sse.ts semantics ported: frame parsing per the EventSource spec (retry must be\n# ASCII digits; comment-only frames skipped; multi-line data joined with \\n) and\n# auto-reconnect resuming from the last event id via Last-Event-ID, with\n# exponential backoff capped at 30s. JSON payloads are parsed when the operation\n# declares a JSON event stream.\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport random\nimport time\nfrom dataclasses import dataclass\nfrom typing import Any, AsyncIterator, Callable, Dict, Iterator, Optional\n\nimport httpx\n\n_FRAME_DELIMITER = "\\n\\n"\n\n\n@dataclass\nclass ServerSentEvent:\n data: Any\n event: Optional[str] = None\n id: Optional[str] = None\n retry: Optional[int] = None\n\n\ndef parse_sse_frame(raw: str, data_kind: str = "text") -> Optional[ServerSentEvent]:\n event = None\n data_lines = []\n event_id = None\n retry = None\n saw_field = False\n for line in raw.replace("\\r\\n", "\\n").replace("\\r", "\\n").split("\\n"):\n if line == "" or line.startswith(":"):\n continue\n field, _, value = line.partition(":")\n if value.startswith(" "):\n value = value[1:]\n saw_field = True\n if field == "event":\n event = value\n elif field == "data":\n data_lines.append(value)\n elif field == "id":\n event_id = value\n elif field == "retry" and value.isdigit():\n retry = int(value)\n if not saw_field:\n return None\n text = "\\n".join(data_lines)\n data: Any = text\n if data_kind == "json" and text != "":\n data = json.loads(text)\n return ServerSentEvent(data=data, event=event, id=event_id, retry=retry)\n\n\ndef iter_sse(\n open_stream: Callable[[Dict[str, str]], Any],\n data_kind: str = "text",\n reconnect: bool = True,\n reconnect_delay: float = 1.0,\n) -> Iterator[ServerSentEvent]:\n """Iterate an event stream. `open_stream(extra_headers)` must return an\n httpx streaming-response context manager; it is reopened on dropped\n connections with Last-Event-ID set (fresh call = fresh auth)."""\n last_event_id: Optional[str] = None\n server_retry: Optional[float] = None\n failures = 0\n while True:\n headers = {"Accept": "text/event-stream"}\n if last_event_id is not None:\n headers["Last-Event-ID"] = last_event_id\n try:\n with open_stream(headers) as response:\n if response.status_code >= 400:\n response.read()\n raise httpx.HTTPStatusError(\n f"SSE request failed with status {response.status_code}",\n request=response.request,\n response=response,\n )\n failures = 0\n buffer = ""\n for chunk in response.iter_text():\n buffer += chunk\n while _FRAME_DELIMITER in buffer:\n raw, buffer = buffer.split(_FRAME_DELIMITER, 1)\n parsed = parse_sse_frame(raw, data_kind)\n if parsed is not None:\n if parsed.id is not None:\n last_event_id = parsed.id\n if parsed.retry is not None:\n server_retry = parsed.retry / 1000\n yield parsed\n # Clean end: flush a trailing frame, then finish (no reconnect).\n if buffer.strip():\n parsed = parse_sse_frame(buffer, data_kind)\n if parsed is not None:\n yield parsed\n return\n except httpx.HTTPStatusError:\n raise # a 4xx/5xx is definitive, not a dropped connection\n except (httpx.TransportError, httpx.TimeoutException):\n if not reconnect:\n raise\n failures += 1\n base = server_retry if server_retry is not None else reconnect_delay\n time.sleep(random.uniform(0, min(base * (2 ** (failures - 1)), 30.0)))\n\n\nasync def aiter_sse(\n open_stream: Callable[[Dict[str, str]], Any],\n data_kind: str = "text",\n reconnect: bool = True,\n reconnect_delay: float = 1.0,\n) -> AsyncIterator[ServerSentEvent]:\n """Async mirror of iter_sse; `open_stream` returns an async context manager."""\n last_event_id: Optional[str] = None\n server_retry: Optional[float] = None\n failures = 0\n while True:\n headers = {"Accept": "text/event-stream"}\n if last_event_id is not None:\n headers["Last-Event-ID"] = last_event_id\n try:\n async with open_stream(headers) as response:\n if response.status_code >= 400:\n await response.aread()\n raise httpx.HTTPStatusError(\n f"SSE request failed with status {response.status_code}",\n request=response.request,\n response=response,\n )\n failures = 0\n buffer = ""\n async for chunk in response.aiter_text():\n buffer += chunk\n while _FRAME_DELIMITER in buffer:\n raw, buffer = buffer.split(_FRAME_DELIMITER, 1)\n parsed = parse_sse_frame(raw, data_kind)\n if parsed is not None:\n if parsed.id is not None:\n last_event_id = parsed.id\n if parsed.retry is not None:\n server_retry = parsed.retry / 1000\n yield parsed\n if buffer.strip():\n parsed = parse_sse_frame(buffer, data_kind)\n if parsed is not None:\n yield parsed\n return\n except httpx.HTTPStatusError:\n raise\n except (httpx.TransportError, httpx.TimeoutException):\n if not reconnect:\n raise\n failures += 1\n base = server_retry if server_retry is not None else reconnect_delay\n await asyncio.sleep(random.uniform(0, min(base * (2 ** (failures - 1)), 30.0)))\n', + '_multipart.py': + '# Multipart bodies for generated Python clients — a typed dict/dataclass body is\n# split into httpx\'s (data, files): bytes and file-like values upload as parts,\n# everything else is form data (nested values JSON-encoded, mirroring the\n# TypeScript runtime\'s FormData serialization).\nfrom __future__ import annotations\n\nimport json\nfrom typing import Any, Dict, Tuple\n\nfrom ._decode import encode\n\n\ndef to_multipart(body: Any) -> Tuple[Dict[str, Any], Dict[str, Any]]:\n wire = encode(body)\n data: Dict[str, Any] = {}\n files: Dict[str, Any] = {}\n for key, value in (wire or {}).items():\n if isinstance(value, (bytes, bytearray)) or hasattr(value, "read"):\n files[key] = value\n elif isinstance(value, (dict, list)):\n data[key] = json.dumps(value)\n else:\n data[key] = value\n return data, files\n', +} as const; + +export type PythonRuntimeModuleName = keyof typeof PYTHON_RUNTIME_SOURCES; diff --git a/packages/client-generator/src/emitters/render-client.ts b/packages/client-generator/src/emitters/render-client.ts new file mode 100644 index 0000000000..aeddc854c6 --- /dev/null +++ b/packages/client-generator/src/emitters/render-client.ts @@ -0,0 +1,501 @@ +// 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 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'; + +const INDENT = ' '; + +/** The request-body TS type: special wrapper types per content-type, else the schema. */ +export function bodyTypeText(rb: RequestBodyModel, dateType: DateType, indent = ''): string { + if (isTypedMultipart(rb)) return tsType(rb.schema, dateType, indent); + switch (rb.contentType) { + case 'multipart/form-data': + return 'FormData'; + case 'application/x-www-form-urlencoded': + return 'URLSearchParams'; + case 'application/octet-stream': + return 'Blob | ArrayBuffer'; + default: + return tsType(rb.schema, dateType, indent); + } +} + +/** The `{ … }` type literal for a params object (query or headers), with per-prop JSDoc. */ +export function paramsTypeText(params: ParamModel[], dateType: DateType, indent = ''): string { + const inner = indent + INDENT; + const lines = params.flatMap((param) => [ + ...tsJsdoc(param.description, param.schema.metadata, inner), + `${inner}${safeIdent(param.name)}${param.required ? '' : '?'}: ${tsType(param.schema, dateType, inner)};`, + ]); + return lines.length === 0 ? '{}' : `{\n${lines.join('\n')}\n${indent}}`; +} + +/** The success-response type + kind (JSON preferred; binary/text fall back; deduped union). */ +export function responseText( + responses: ResponseBodyModel[], + dateType: DateType, + indent = '' +): { type: string; kind: 'json' | 'blob' | 'text' | 'void' } { + if (responses.length === 0) return { type: 'void', kind: 'void' }; + const jsonResponse = responses.find((r) => r.contentType.toLowerCase().includes('json')); + if (jsonResponse) return { type: tsType(jsonResponse.schema, dateType, indent), kind: 'json' }; + const members: string[] = []; + const seen = new Set(); + let hasBinary = false; + let hasText = false; + for (const response of responses) { + let member: string; + if ( + response.contentType.startsWith('image/') || + response.contentType === 'application/octet-stream' + ) { + member = 'Blob'; + hasBinary = true; + } else if (response.contentType.startsWith('text/')) { + member = 'string'; + hasText = true; + } else { + member = tsType(response.schema, dateType, indent); + } + if (seen.has(member)) continue; + seen.add(member); + members.push(member); + } + return { type: members.join(' | '), kind: hasBinary ? 'blob' : hasText ? 'text' : 'json' }; +} + +/** The deduped error-response body types, or `[]` when none. */ +export function errorTypeTexts( + responses: ResponseBodyModel[], + dateType: DateType, + indent = '' +): string[] { + const seen = new Set(); + const members: string[] = []; + for (const response of responses) { + const member = tsType(response.schema, dateType, indent); + if (seen.has(member)) continue; + seen.add(member); + members.push(member); + } + return members; +} + +/** 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); + return schema ? tsType(schema, dateType, indent) : 'string'; +} + +/** A `(?): ` line, inlining the type when `` collides with a schema. */ +function inputPropLine( + key: string, + alias: string, + inlineType: () => string, + required: boolean, + schemaNames: Set, + indent: string +): string { + const type = schemaNames.has(alias) ? inlineType() : alias; + return `${indent}${key}${required ? '' : '?'}: ${type};`; +} + +/** The named schema a `ref` chain ends at, for deciding whether a body can merge. */ +function resolvedSchema( + schema: SchemaModel, + schemas: readonly NamedSchemaModel[] | undefined +): 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 = schemas?.find((candidate) => candidate.name === name); + if (named === undefined) return undefined; + current = named.schema; + } + return current; +} + +/** + * The property names of a body a merged call spreads. An `allOf` composition contributes the + * names of every member, because that is what the merged object ends up carrying; a member + * that is not an object makes the whole body unspreadable. + */ +function mergedBodyProperties( + schema: SchemaModel, + schemas: readonly NamedSchemaModel[] | undefined +): string[] | undefined { + const resolved = resolvedSchema(schema, schemas); + if (resolved?.kind === 'object') return resolved.properties.map((property) => property.name); + if (resolved?.kind !== 'intersection') return undefined; + // Deduplicated: `allOf` members routinely redeclare a property to refine it, and the + // merged body still carries one key for it — counting it twice would read as a collision + // and push a mergeable operation back to the namespaced shape. + const names = new Set(); + for (const member of resolved.members) { + const memberNames = mergedBodyProperties(member, schemas); + if (memberNames === undefined) return undefined; + for (const name of memberNames) names.add(name); + } + return [...names]; +} + +/** + * How a flat-style call spells one operation's inputs. Every parameter sits at one level, + * and a REQUIRED object body contributes its own properties — an optional body cannot + * (omitting it and omitting its required properties would look the same), and neither can + * an array, scalar, or binary body, so those keep the `body` key. + * + * When one name appears in two layers (a path and a query parameter of the same name, which + * OpenAPI permits) a merged call cannot say which is which, so that operation keeps the + * namespaced shape. The caller reports it once. + */ +export function flatInputShape( + op: OperationModel, + schemas: readonly NamedSchemaModel[] | undefined +): { mergeBody: boolean } | { collisions: string[] } { + const params = [ + ...templatePathParams(op), + ...op.queryParams, + ...op.headerParams, + ...op.cookieParams, + ]; + const bodyProperties = + (op.requestBody?.required ?? false) && op.requestBody !== undefined + ? mergedBodyProperties(op.requestBody.schema, schemas) + : undefined; + const mergeBody = bodyProperties !== undefined; + const counts = new Map(); + for (const param of params) counts.set(param.name, (counts.get(param.name) ?? 0) + 1); + // An unmerged body keeps the `body` key, which a parameter of that name would shadow. + if (op.requestBody && !mergeBody) counts.set('body', (counts.get('body') ?? 0) + 1); + for (const property of bodyProperties ?? []) { + counts.set(property, (counts.get(property) ?? 0) + 1); + } + const collisions = [...counts].filter(([, count]) => count > 1).map(([paramName]) => paramName); + return collisions.length > 0 ? { collisions } : { mergeBody }; +} + +/** `: ` lines for parameters written at one level (the merged, flat shape). */ +function mergedParamLines(params: ParamModel[], ctx: EmitContext, inner: string): string[] { + return params.flatMap((param) => [ + ...tsJsdoc(param.description, param.schema.metadata, inner), + `${inner}${safeIdent(param.name)}${param.required ? '' : '?'}: ${tsType(param.schema, ctx.dateType, inner)};`, + ]); +} + +/** The `Variables` object type literal (see operation-aliases.ts for the contract). */ +export function variablesTypeText( + op: OperationModel, + name: string, + ctx: EmitContext, + indent = '' +): string { + const { dateType, schemaNames } = ctx; + const inner = indent + INDENT; + const flat = ctx.argsStyle === 'flat' ? flatInputShape(op, ctx.schemas) : undefined; + if (flat !== undefined && 'mergeBody' in flat) { + return mergedVariablesText(op, name, ctx, flat.mergeBody, indent); + } + const lines: string[] = []; + const pathParams = templatePathParams(op); + if (pathParams.length > 0) { + lines.push( + inputPropLine( + 'path', + `${name}Path`, + () => paramsTypeText(pathParams, dateType, inner), + true, + schemaNames, + inner + ) + ); + } + if (op.queryParams.length > 0) { + lines.push( + inputPropLine( + 'query', + `${name}Query`, + () => paramsTypeText(op.queryParams, dateType, inner), + op.queryParams.some((p) => p.required), + schemaNames, + inner + ) + ); + } + if (op.requestBody) { + lines.push( + inputPropLine( + 'body', + `${name}Body`, + () => bodyTypeText(op.requestBody!, dateType, inner), + op.requestBody.required, + schemaNames, + inner + ) + ); + } + if (op.headerParams.length > 0) { + lines.push( + inputPropLine( + 'headers', + `${name}Headers`, + () => paramsTypeText(op.headerParams, dateType, inner), + op.headerParams.some((p) => p.required), + schemaNames, + inner + ) + ); + } + if (op.cookieParams.length > 0) { + lines.push( + inputPropLine( + 'cookies', + `${name}Cookies`, + () => paramsTypeText(op.cookieParams, dateType, inner), + op.cookieParams.some((p) => p.required), + schemaNames, + inner + ) + ); + } + return lines.length === 0 ? '{}' : `{\n${lines.join('\n')}\n${indent}}`; +} + +/** + * The merged (`argsStyle: flat`) `Variables`: every parameter at one level, intersected + * with the body alias when the body merges. Intersecting reuses the `Body` alias rather + * than reprinting its properties, so one body type stays one type. + */ +function mergedVariablesText( + op: OperationModel, + name: string, + ctx: EmitContext, + mergeBody: boolean, + indent: string +): string { + const inner = indent + INDENT; + const lines = mergedParamLines( + [...templatePathParams(op), ...op.queryParams, ...op.headerParams, ...op.cookieParams], + ctx, + inner + ); + if (op.requestBody && !mergeBody) { + lines.push( + inputPropLine( + 'body', + `${name}Body`, + () => bodyTypeText(op.requestBody!, ctx.dateType, inner), + op.requestBody.required, + ctx.schemaNames, + inner + ) + ); + } + const bodyRef = ctx.schemaNames.has(`${name}Body`) + ? bodyTypeText(op.requestBody!, ctx.dateType, indent) + : `${name}Body`; + const object = lines.length === 0 ? '{}' : `{\n${lines.join('\n')}\n${indent}}`; + if (!mergeBody) return object; + return lines.length === 0 ? bodyRef : `${object} & ${bodyRef}`; +} + +/** The raw success ref: the `Result` alias, or the inline type when that name collides. */ +function rawResultText(op: OperationModel, ctx: EmitContext, indent: string): string { + const resultName = `${pascalCase(op.name)}Result`; + return ctx.schemaNames.has(resultName) + ? responseText(op.successResponses, ctx.dateType, indent).type + : resultName; +} + +/** The `Result<…, E>` error argument (result mode): `unknown`, the alias, or the inline union. */ +function errorArgText(op: OperationModel, ctx: EmitContext, indent: string): string { + const members = errorTypeTexts(op.errorResponses, ctx.dateType, indent); + if (members.length === 0) return 'unknown'; + const alias = `${pascalCase(op.name)}Error`; + if (!ctx.schemaNames.has(alias)) return alias; + return members.join(' | '); +} + +/** `export type Ops = { : { args; result; item?; page?; kind? } }` — what `createClient` consumes. */ +export function renderOpsType( + model: ApiModel, + idents: Map, + ctx: EmitContext +): string { + const ops = allOperations(model.services); + if (ops.length === 0) return ''; + const memberBlocks = ops.flatMap((op) => { + const ident = idents.get(op.name)!; + const name = pascalCase(op.name); + const inner = INDENT + INDENT; + const args = variablesTypeText(op, name, ctx, inner); + const sse = isSseOp(op); + const result = sse + ? sseEventText(op, ctx.dateType, inner) + : ctx.errorMode === 'result' + ? `Result<${rawResultText(op, ctx, inner)}, ${errorArgText(op, ctx, inner)}>` + : rawResultText(op, ctx, inner); + const lines = [`${inner}args: ${args};`, `${inner}result: ${result};`]; + // Result-mode entries mark themselves so the runtime's mapped methods skip the + // throw-only envelope typing; declared headers type the `{ envelope: true }` bag. + if (ctx.errorMode === 'result' && !sse) lines.push(`${inner}mode: "result";`); + const responseHeaders = op.successResponseHeaders; + if (responseHeaders && responseHeaders.length > 0) { + lines.push( + `${inner}headers: ${responseHeadersTypeText(responseHeaders, ctx.schemas, inner)};` + ); + } + const paginated = ctx.pagination?.get(op.name); + if (paginated) { + lines.push(`${inner}item: ${tsType(paginated.itemSchema, ctx.dateType, inner)};`); + if (ctx.errorMode === 'result') { + lines.push(`${inner}page: ${rawResultText(op, ctx, inner)};`); + } + } + if (sse) lines.push(`${inner}kind: "sse";`); + return [`${INDENT}${ident}: {`, ...lines, `${INDENT}};`]; + }); + return [ + ...tsJsdoc( + "Per-operation `args`/`result` shapes (plus `kind: 'sse'` for event streams) — the\n" + + 'type-level companion of `OPERATIONS` that gives `createClient` its typed methods.', + undefined, + '' + ), + 'export type Ops = {', + ...memberBlocks, + '};', + ].join('\n'); +} + +/** One operation's `*` aliases (Result/Error/Path/Query/Body/Headers/Cookies/Variables), collision-suppressed. */ +export function renderAliases(op: OperationModel, ctx: EmitContext): string { + const { dateType, schemaNames } = ctx; + const name = pascalCase(op.name); + const sse = isSseOp(op); + const { hasInputs } = operationSignature(op); + const blocks: string[] = []; + + if (!sse) { + const resultName = `${name}Result`; + if (!schemaNames.has(resultName)) { + blocks.push( + `export type ${resultName} = ${responseText(op.successResponses, dateType).type};` + ); + } + if (ctx.errorMode === 'result') { + const members = errorTypeTexts(op.errorResponses, dateType); + const errorAlias = `${name}Error`; + if (members.length > 0 && !schemaNames.has(errorAlias)) { + blocks.push(`export type ${errorAlias} = ${members.join(' | ')};`); + } + } + } + const pathParams = templatePathParams(op); + if (pathParams.length > 0 && !schemaNames.has(`${name}Path`)) { + blocks.push(`export type ${name}Path = ${paramsTypeText(pathParams, dateType)};`); + } + if (op.queryParams.length > 0 && !schemaNames.has(`${name}Query`)) { + blocks.push(`export type ${name}Query = ${paramsTypeText(op.queryParams, dateType)};`); + } + if (op.requestBody && !schemaNames.has(`${name}Body`)) { + blocks.push(`export type ${name}Body = ${bodyTypeText(op.requestBody, dateType)};`); + } + if (op.headerParams.length > 0 && !schemaNames.has(`${name}Headers`)) { + blocks.push(`export type ${name}Headers = ${paramsTypeText(op.headerParams, dateType)};`); + } + // Response headers (envelope) — distinct from request `Headers`. + const responseHeaders = op.successResponseHeaders; + if (responseHeaders && responseHeaders.length > 0 && !schemaNames.has(`${name}ResponseHeaders`)) { + blocks.push( + `export type ${name}ResponseHeaders = ${responseHeadersTypeText(responseHeaders, ctx.schemas)};` + ); + } + if (op.cookieParams.length > 0 && !schemaNames.has(`${name}Cookies`)) { + blocks.push(`export type ${name}Cookies = ${paramsTypeText(op.cookieParams, dateType)};`); + } + if (hasInputs && !schemaNames.has(`${name}Variables`)) { + blocks.push(`export type ${name}Variables = ${variablesTypeText(op, name, ctx)};`); + } + return blocks.join('\n\n'); +} + +/** + * Schema names the ENTRY file's own types reference — the split layout's type-only + * import list. Derived from the IR (the exact sources the alias/Ops renderers type): + * every ref reachable from operation inputs, success responses, error responses + * (result mode only — throw mode never renders them), and pagination item schemas. + * Named schema BODIES are not expanded: a ref renders as its bare name. + */ +export function collectEntrySchemaRefs(model: ApiModel, ctx: EmitContext): string[] { + const referenced = new Set(); + const walk = (schema: SchemaModel): void => { + switch (schema.kind) { + case 'ref': + referenced.add(schema.name); + return; + case 'omit': + referenced.add(schema.base); + return; + case 'array': + walk(schema.items); + return; + case 'record': + walk(schema.value); + return; + case 'object': + for (const property of schema.properties) walk(property.schema); + return; + case 'union': + case 'intersection': + for (const member of schema.members) walk(member); + return; + default: + return; + } + }; + for (const op of allOperations(model.services)) { + for (const param of [ + ...op.pathParams, + ...op.queryParams, + ...op.headerParams, + ...op.cookieParams, + ]) { + walk(param.schema); + } + if (op.requestBody) walk(op.requestBody.schema); + for (const response of op.successResponses) { + walk(response.schema); + // SSE responses type their event payload from the stream's item schema. + if (response.itemSchema) walk(response.itemSchema); + } + if (ctx.errorMode === 'result') { + for (const response of op.errorResponses) walk(response.schema); + } + const paginated = ctx.pagination?.get(op.name); + if (paginated) walk(paginated.itemSchema); + } + return [...referenced].filter((name) => ctx.schemaNames.has(name)).sort(); +} diff --git a/packages/client-generator/src/emitters/reserved-names.ts b/packages/client-generator/src/emitters/reserved-names.ts index d2d6f9a983..7fa5049d5f 100644 --- a/packages/client-generator/src/emitters/reserved-names.ts +++ b/packages/client-generator/src/emitters/reserved-names.ts @@ -4,12 +4,11 @@ // import or declare, the platform globals the emitted code references bare (a // same-named schema TYPE would shadow them), and every top-level declaration of the // runtime sources (in embed mode ALL of them — even module-local helpers — share -// the module scope with the generated code). The runtime layer is parsed from -// `RUNTIME_SOURCES`, so it tracks the real runtime with no hand-maintained list to -// drift. +// the module scope with the generated code). The runtime layer is precomputed from +// the runtime sources at prepare time (`RUNTIME_DECLARED_NAMES`), so it tracks the +// real runtime with no hand-maintained list to drift. -import { RUNTIME_SOURCES } from './runtime-sources.js'; -import { parseStatements, ts } from './ts.js'; +import { RUNTIME_DECLARED_NAMES } from './runtime-sources.js'; /** Module-scope identifiers every package-mode sdk file emits or imports — never renamed. */ export const WIRING_NAMES = [ @@ -69,6 +68,7 @@ const GLOBAL_NAMES = [ 'ArrayBuffer', 'ArrayBufferView', 'AsyncGenerator', + 'AsyncIterable', 'Blob', 'BodyInit', 'Boolean', @@ -100,6 +100,7 @@ const GLOBAL_NAMES = [ 'TextDecoder', 'TextEncoder', 'TypeError', + 'Uint8Array', 'URL', 'URLSearchParams', 'btoa', @@ -116,30 +117,18 @@ const GLOBAL_NAMES = [ let cached: Set | undefined; -/** Every name the generated modules reserve: wiring + satellite + globals + runtime declarations. */ +/** Every name the generated modules reserve: wiring + satellite + globals + runtime + * declarations. The runtime layer is precomputed at prepare time + * (`RUNTIME_DECLARED_NAMES`), so building this set never needs the TS parser — + * keeping the pipeline `typescript`-free for non-TS generator selections. */ export function reservedModuleNames(): Set { if (cached === undefined) { - cached = new Set([...WIRING_NAMES, ...SATELLITE_NAMES, ...GLOBAL_NAMES]); - for (const source of Object.values(RUNTIME_SOURCES)) { - for (const statement of parseStatements(source)) collectDeclaredName(statement, cached); - } + cached = new Set([ + ...WIRING_NAMES, + ...SATELLITE_NAMES, + ...GLOBAL_NAMES, + ...RUNTIME_DECLARED_NAMES, + ]); } return cached; } - -function collectDeclaredName(statement: ts.Statement, into: Set): void { - if ( - (ts.isFunctionDeclaration(statement) || - ts.isClassDeclaration(statement) || - ts.isInterfaceDeclaration(statement) || - ts.isTypeAliasDeclaration(statement) || - ts.isEnumDeclaration(statement)) && - statement.name !== undefined - ) { - into.add(statement.name.text); - } else if (ts.isVariableStatement(statement)) { - for (const declaration of statement.declarationList.declarations) { - if (ts.isIdentifier(declaration.name)) into.add(declaration.name.text); - } - } -} diff --git a/packages/client-generator/src/emitters/response-headers.ts b/packages/client-generator/src/emitters/response-headers.ts index bab047ea5e..c6d7e52618 100644 --- a/packages/client-generator/src/emitters/response-headers.ts +++ b/packages/client-generator/src/emitters/response-headers.ts @@ -1,6 +1,7 @@ -// Success-response header helpers: descriptor parse hints + Ops / alias type shapes +// 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, @@ -9,9 +10,8 @@ import type { import type { ResponseHeaderSpec } from '../runtime/types.js'; import { uniqueIdent } from './identifier.js'; import { headerPropertyKey } from './support.js'; -import { ts } from './ts.js'; -const { factory } = ts; +const INDENT = ' '; type PlannedResponseHeader = ResponseHeaderModel & { key: string; @@ -20,49 +20,15 @@ type PlannedResponseHeader = ResponseHeaderModel & { /** * Runtime coerce hint from a header schema (complex schemas fall back to string). - * Resolves `$ref` through `schemas`, peels nullable unions and metadata-only - * `allOf` intersections, then maps scalar/literal/enum leaves to number/boolean. + * Delegates to the neutral `headerCoerceType`; JavaScript has one number type, + * so `integer` collapses to `number`. */ export function headerParseType( schema: SchemaModel, - schemas: readonly NamedSchemaModel[] = [], - seen: Set = new Set() + schemas: readonly NamedSchemaModel[] = [] ): ResponseHeaderSpec['type'] { - if (schema.kind === 'ref') { - if (seen.has(schema.name)) return 'string'; - seen.add(schema.name); - const named = schemas.find((entry) => entry.name === schema.name); - if (named === undefined) return 'string'; - return headerParseType(named.schema, schemas, seen); - } - if (schema.kind === 'intersection') { - // Drop unknown members (constraint-only allOf branches) and unwrap a sole remainder. - const members = schema.members.filter((member) => member.kind !== 'unknown'); - if (members.length === 1) return headerParseType(members[0], schemas, seen); - const types = [ - ...new Set(members.map((member) => headerParseType(member, schemas, new Set(seen)))), - ]; - return types.length === 1 ? types[0] : 'string'; - } - // Nullable wrappers (`boolean | null`, OpenAPI 3.0 `nullable`) unwrap to the inner type. - if (schema.kind === 'union') { - const members = schema.members.filter((member) => member.kind !== 'null'); - if (members.length === 1) return headerParseType(members[0], schemas, seen); - return 'string'; - } - if (schema.kind === 'scalar') { - if (schema.scalar === 'integer' || schema.scalar === 'number') return 'number'; - if (schema.scalar === 'boolean') return 'boolean'; - } - if (schema.kind === 'literal') { - if (typeof schema.value === 'number') return 'number'; - if (typeof schema.value === 'boolean') return 'boolean'; - } - if (schema.kind === 'enum') { - if (schema.scalar === 'integer' || schema.scalar === 'number') return 'number'; - if (schema.scalar === 'boolean') return 'boolean'; - } - return 'string'; + const coerce = headerCoerceType(schema, { schemas }); + return coerce === 'integer' ? 'number' : coerce; } /** Descriptor `responseHeaders` entries from the success response's declared headers. */ @@ -79,21 +45,17 @@ export function responseHeaderSpecs( })); } -/** Type literal for Ops.`headers` / `ResponseHeaders`. */ -export function responseHeadersTypeLiteral( +/** Type-literal text for Ops.`headers` / `ResponseHeaders`, rendered at `indent`. */ +export function responseHeadersTypeText( headers: ResponseHeaderModel[], - schemas: readonly NamedSchemaModel[] = [] -): ts.TypeNode { - return factory.createTypeLiteralNode( - planResponseHeaders(headers, schemas).map((header) => { - return factory.createPropertySignature( - undefined, - factory.createIdentifier(header.key), - header.required === true ? undefined : factory.createToken(ts.SyntaxKind.QuestionToken), - headerTypeNode(header.type) - ); - }) + schemas: readonly NamedSchemaModel[] = [], + indent = '' +): string { + const inner = indent + INDENT; + const lines = planResponseHeaders(headers, schemas).map( + (header) => `${inner}${header.key}${header.required === true ? '' : '?'}: ${header.type};` ); + return lines.length === 0 ? '{}' : `{\n${lines.join('\n')}\n${indent}}`; } function planResponseHeaders( @@ -107,9 +69,3 @@ function planResponseHeaders( type: headerParseType(header.schema, schemas), })); } - -function headerTypeNode(type: ResponseHeaderSpec['type']): ts.TypeNode { - if (type === 'number') return factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword); - if (type === 'boolean') return factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword); - return factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); -} diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index 2c8d3e0c4a..c0e746d620 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -1,7 +1,7 @@ // GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. export const RUNTIME_SOURCES = { 'types.ts': - "/**\n * The public type surface of the client runtime — `@redocly/client-generator`'s\n * app-facing runtime module. Pure types, no runtime code (excluded from coverage).\n * The generator emits `OPERATIONS` literals typed\n * `satisfies Record` against this module, so an\n * incompatible runtime/generated pair fails the consumer's build (the semver skew guard).\n */\n\n/** How one operation parameter is sent: its location plus OpenAPI query-serialization hints. */\nexport type ParamSpec = {\n name: string;\n in: 'path' | 'query' | 'header' | 'cookie';\n style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject';\n explode?: boolean;\n allowReserved?: boolean;\n};\n\n/** One security scheme, denormalized onto the operation (`scheme` names the spec's scheme). */\nexport type SecuritySpec =\n | { scheme: string; kind: 'bearer' | 'basic' }\n | { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' };\n\n/**\n * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members).\n * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value.\n */\nexport type PaginationSpec =\n | {\n style: 'cursor';\n /** The query param the iterator advances with the response's cursor. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the next cursor in the page. */\n nextCursor: string;\n /** Optional pointer to a boolean \"more pages\" flag — `false` stops iteration. */\n hasMore?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n style: 'offset' | 'page';\n /** The numeric query param the iterator advances. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n /** RFC 8288: follow the response's `Link` header `rel=\"next\"`; stop when absent. */\n style: 'link';\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n };\n\n/** The frozen data contract between generated code and the runtime: one operation's wire shape. */\nexport type OperationDescriptor = {\n id: string;\n method: string;\n path: string;\n tags?: readonly string[];\n params?: readonly ParamSpec[];\n /** `multipart: true` marks a typed object body serialized to FormData by the runtime. */\n body?: { contentType: string; multipart?: boolean };\n /** Defaults to `'json'` (content-type negotiation on parse). */\n responseKind?: 'json' | 'text' | 'blob' | 'void' | 'sse';\n sseDataKind?: 'json' | 'text';\n /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */\n security?: readonly (readonly SecuritySpec[])[];\n pagination?: PaginationSpec;\n /**\n * Declared success-response headers for throw-mode `{ envelope: true }`.\n * `name` is the lowercased wire name; `key` is the camelCase envelope property.\n */\n responseHeaders?: readonly ResponseHeaderSpec[];\n};\n\n/** One declared response header the runtime coerces into the envelope `headers` object. */\nexport type ResponseHeaderSpec = {\n name: string;\n key: string;\n type: 'string' | 'number' | 'boolean';\n};\n\n/** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */\nexport type QueryValue =\n | string\n | number\n | boolean\n | null\n | undefined\n | Array\n | Record;\n\n/** A credential: a literal, or a (possibly async) function resolved per request (refresh flows). */\nexport type TokenProvider = string | (() => string | Promise);\n\n/** Per-instance credentials, keyed by the scheme kinds the runtime can inject. */\nexport type AuthCredentials = {\n bearer?: TokenProvider;\n basic?: { username: string; password: string };\n apiKey?: Record;\n};\n\n/** Backoff shape: 'fixed' = constant delay; 'exponential' = doubling per attempt. */\nexport type RetryStrategy = 'fixed' | 'exponential';\n\n/**\n * The operation's identity, exposed to middleware for targeting (`ctx.operation`).\n * Generated clients instantiate the type parameters with the spec's literal unions\n * (`OperationId`/`OperationPath`/`OperationTag`) so a misspelled operation id in a\n * middleware comparison fails to compile; the string defaults keep every\n * spec-independent consumer (`runtime-contract.ts`, the runtime internals) working\n * with the base shape. `tags` stays mutable (`Tag[]`) so setup-contract types\n * (byte-locked to generated output) remain assignable through middleware callbacks.\n */\nexport type OperationContext<\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n> = { id: Id; path: Path; tags: Tag[] };\n\n/** The mutable request context threaded through the middleware chain. */\nexport type RequestContext = {\n url: string;\n method: string;\n headers: Record;\n body?: unknown;\n operation: Op;\n};\n\n/** The failed attempt handed to a custom `retryOn`: exactly one of `response`/`error` is set. */\nexport type RetryContext = {\n attempt: number;\n request: RequestContext;\n response?: Response;\n error?: unknown;\n};\n\n/** Opt-in retry policy; a per-call override merges field-by-field over the config policy. */\nexport type RetryConfig = {\n retries?: number;\n retryDelay?: number;\n retryStrategy?: RetryStrategy;\n jitter?: boolean;\n retryOn?: (ctx: RetryContext) => boolean | Promise;\n};\n\n/**\n * Structural stand-in for the runtime's ApiError so this module stays import-free\n * (pure types); the real `ApiError` class is assignable to it.\n */\nexport type ApiErrorLike = globalThis.Error & {\n url: string;\n status: number;\n statusText: string;\n body: unknown;\n};\n\n/** One interceptor: any subset of the three hooks. */\nexport type Middleware = {\n onRequest?: (ctx: RequestContext) => void | Promise;\n onResponse?: (\n response: Response,\n ctx: RequestContext\n ) => Response | void | Promise;\n /** Throw mode only: may map/replace the error. */\n // `globalThis.Error` so a spec schema named `Error` cannot shadow it in inline mode.\n onError?: (\n error: ApiErrorLike,\n ctx: RequestContext\n ) => globalThis.Error | Promise;\n};\n\n/** Client configuration: transport, defaults, retry policy, middleware, and credentials. */\nexport type ClientConfig = {\n serverUrl?: string;\n fetch?: typeof fetch;\n headers?:\n | Record\n | (() => Record | Promise>);\n retry?: RetryConfig;\n /** Milliseconds before a request attempt aborts (covers the body read too; each retry\n * attempt gets a fresh budget). Per-call `timeout` overrides it, `0` disables it.\n * SSE streams are long-lived by design and never inherit this value. */\n timeout?: number;\n /** Send an `Idempotency-Key` header on POST/PATCH (one stable key per logical call,\n * reused across retry attempts) — which also makes those retries safe under the\n * default retry policy. `true` generates a UUID per call; a function supplies the key. */\n idempotencyKey?: boolean | (() => string);\n /** Identifies this client to the API via an `X-Redocly-Client` header (the generator\n * bakes a default). Sent only OUTSIDE browsers — a custom header would force a CORS\n * preflight. Override with your own value, or `false` to disable. */\n clientHeader?: string | false;\n middleware?: Middleware[];\n auth?: AuthCredentials;\n /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */\n errorMode?: 'throw' | 'result';\n onRequest?: Middleware['onRequest'];\n onResponse?: Middleware['onResponse'];\n onError?: Middleware['onError'];\n};\n\n/** Response readers for the per-call `parseAs` override. */\nexport type ParseAs = 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'stream';\n\n/** Per-call options: standard `RequestInit` plus a retry override, a timeout override\n * (`0` disables the config default), and a forced reader. */\nexport type RequestOptions = RequestInit & {\n retry?: RetryConfig;\n timeout?: number;\n /** Per-call idempotency key: a literal key, `true` to generate one, `false` to skip. */\n idempotencyKey?: string | boolean | (() => string);\n parseAs?: ParseAs;\n /**\n * Throw mode only: return `{ data, headers, response }` instead of the parsed body;\n * ignored in result mode. The explicit `| undefined` keeps the wrappers' emitted\n * `envelope: undefined` strip legal under `exactOptionalPropertyTypes`.\n */\n envelope?: boolean | undefined;\n};\n\n/** Throw-mode success envelope when `RequestOptions.envelope` is `true`. */\nexport type Envelope> = {\n data: TData;\n headers: THeaders;\n response: Response;\n};\n\n/** Per-call options for an SSE stream; reconnect defaults to true. */\nexport type SseOptions = RequestInit & { reconnect?: boolean; reconnectDelay?: number };\n\n/** A single decoded Server-Sent Event with its payload typed from the spec. */\nexport type ServerSentEvent = { event?: string; data: T; id?: string; retry?: number };\n\n/** Result-mode return shape: exactly one of `data`/`error` is set. */\nexport type Result =\n | { data: TData; error: undefined; response: Response }\n | { data: undefined; error: TError; response: Response };\n\n/**\n * The generated `Ops` type's shape: per-operation args/result, plus `kind: 'sse'` for\n * streams and, for paginated operations, `item` (the page's element type) and — on\n * result-mode clients only — `page` (the RAW page type `.pages()` yields, since\n * iteration unwraps the `Result` envelope the one-shot `result` carries).\n */\nexport type OpsShape = Record<\n string,\n {\n args: object;\n result: unknown;\n kind?: 'sse';\n item?: unknown;\n page?: unknown;\n /** Declared success-response headers for `{ envelope: true }` (camelCase keys). */\n headers?: object;\n /** Result-mode entries ignore the throw-only `envelope` option. */\n mode?: 'result';\n }\n>;\n\n/** The always-present client members (assigned after the operation loop — they win collisions). */\nexport type ClientCore = {\n /** Merge into the config; note `middleware` REPLACES the chain (use `use()` to compose). */\n configure(config: ClientConfig): void;\n /** Append interceptors (composes with baked/publisher middleware). */\n use(...middleware: Middleware[]): void;\n auth: {\n bearer(token: TokenProvider): void;\n basic(username: string, password: string): void;\n apiKey(scheme: string, value: TokenProvider): void;\n };\n};\n\n/**\n * The standard TypeScript optionality probe: `{}` has no required members, so\n * `{} extends A` is true exactly when every member of `A` is optional.\n */\n// oxlint-disable-next-line typescript/no-empty-object-type\ntype NoRequiredKeys = {} extends A ? true : false;\n\n/**\n * The page type `.pages()` yields: the RAW page declared by `page` (the generator\n * writes it only on result-mode paginated entries, whose `result` is the envelope),\n * or the method's own `result` (throw mode — already the raw page).\n */\ntype PageOf = Entry extends { page: unknown }\n ? Entry['page']\n : Entry['result'];\n\n/**\n * The auto-pagination members intersected onto a paginated method — present exactly when\n * the Ops entry declares `item` (the generator writes it only for paginated operations).\n * Args optionality mirrors the method's own; `unknown` otherwise (identity under `&`).\n * Iteration is error-mode-agnostic: `.pages()`/`.items()` yield raw pages/items, and a\n * failed page aborts iteration by throwing `ApiError`, even on result-mode clients; the\n * `onError` middleware hook (throw-mode-only) is not invoked.\n */\ntype Paginated = 'item' extends keyof Entry\n ? NoRequiredKeys extends true\n ? {\n pages(args?: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args?: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : {\n pages(args: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : unknown;\n\n/**\n * The stable identity every client method carries: the SPEC operationId (also set as\n * `fn.name`, but `operationId` is the explicit, minification-proof form) — a robust\n * cache key for consumer wrappers (react-query keys and the like).\n */\nexport type OperationMethodIdentity = { readonly operationId: string };\n\n/** Declared response-header bag for an Ops entry; empty object when none are declared. */\ntype HeadersOf = 'headers' extends keyof Entry\n ? NonNullable\n : Record;\n\n/**\n * Return type of a throw-mode call: the body by default, `Envelope<…>` for a literal\n * `envelope: true`, their union when the flag is a widened `boolean`. Exact\n * `RequestOptions` stays the body — pre-envelope package-mode flat sugar typed every\n * `init` parameter as `RequestOptions`, and widening that would break upgrades without\n * a regenerate. The `keyof` presence gate keeps `{ headers }` / `{ signal }` as the body\n * (`TInit['envelope']` through `TInit & RequestOptions` would otherwise be\n * `boolean | undefined`).\n */\nexport type EnvelopeResult<\n TData,\n THeaders,\n TInit extends RequestOptions | undefined,\n> = TInit extends undefined\n ? TData\n : RequestOptions extends TInit\n ? TInit extends RequestOptions\n ? TData\n : EnvelopeResultForKnownInit\n : EnvelopeResultForKnownInit;\n\ntype EnvelopeResultForKnownInit = 'envelope' extends keyof TInit\n ? [TInit['envelope' & keyof TInit]] extends [true]\n ? Envelope\n : [TInit['envelope' & keyof TInit]] extends [false | undefined]\n ? TData\n : TData | Envelope\n : TData;\n\n/** A one-shot method whose return shape never varies with per-call options. */\ntype BodyMethod =\n NoRequiredKeys extends true\n ? (args?: Entry['args'], init?: RequestOptions) => Promise\n : (args: Entry['args'], init?: RequestOptions) => Promise;\n\n/**\n * One-shot (non-SSE) method: default returns the body; `{ envelope: true }` returns\n * `{ data, headers, response }` with typed declared headers.\n */\ntype ThrowMethod =\n NoRequiredKeys extends true\n ? (\n args?: Entry['args'],\n init?: Init\n ) => Promise, Init>>\n : (\n args: Entry['args'],\n init?: Init\n ) => Promise, Init>>;\n\n/** The typed instance client: one bound method per operation plus the core members. */\nexport type Client = {\n [K in keyof Ops]: Ops[K] extends { kind: 'sse' }\n ? (NoRequiredKeys extends true\n ? (\n args?: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>\n : (\n args: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>) &\n OperationMethodIdentity\n : (Ops[K] extends { mode: 'result' } ? BodyMethod : ThrowMethod) &\n OperationMethodIdentity &\n Paginated;\n} & ClientCore;\n", + "/**\n * The public type surface of the client runtime — `@redocly/client-generator`'s\n * app-facing runtime module. Pure types, no runtime code (excluded from coverage).\n * The generator emits `OPERATIONS` literals typed\n * `satisfies Record` against this module, so an\n * incompatible runtime/generated pair fails the consumer's build (the semver skew guard).\n */\n\n/** How one operation parameter is sent: its location plus OpenAPI query-serialization hints. */\nexport type ParamSpec = {\n name: string;\n in: 'path' | 'query' | 'header' | 'cookie';\n style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject';\n explode?: boolean;\n allowReserved?: boolean;\n};\n\n/** One security scheme, denormalized onto the operation (`scheme` names the spec's scheme). */\nexport type SecuritySpec =\n | { scheme: string; kind: 'bearer' | 'basic' }\n | { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' };\n\n/**\n * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members).\n * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value.\n */\nexport type PaginationSpec =\n | {\n style: 'cursor';\n /** The query param the iterator advances with the response's cursor. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the next cursor in the page. */\n nextCursor: string;\n /** Optional pointer to a boolean \"more pages\" flag — `false` stops iteration. */\n hasMore?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n style: 'offset' | 'page';\n /** The numeric query param the iterator advances. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n /** RFC 8288: follow the response's `Link` header `rel=\"next\"`; stop when absent. */\n style: 'link';\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n };\n\n/** The frozen data contract between generated code and the runtime: one operation's wire shape. */\nexport type OperationDescriptor = {\n id: string;\n method: string;\n path: string;\n tags?: readonly string[];\n params?: readonly ParamSpec[];\n /** `multipart: true` marks a typed object body serialized to FormData by the runtime. */\n body?: { contentType: string; multipart?: boolean };\n /** Defaults to `'json'` (content-type negotiation on parse). */\n responseKind?: 'json' | 'text' | 'blob' | 'void' | 'sse';\n sseDataKind?: 'json' | 'text';\n /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */\n security?: readonly (readonly SecuritySpec[])[];\n pagination?: PaginationSpec;\n /**\n * `'grouped'` marks an operation that takes its inputs namespaced by layer even on a\n * `argsStyle: 'flat'` client — the generator sets it where a merged call could not carry\n * one name for two layers, and the operation's own input type says the same.\n */\n argsStyle?: 'grouped';\n /**\n * Declared success-response headers for throw-mode `{ envelope: true }`.\n * `name` is the lowercased wire name; `key` is the camelCase envelope property.\n */\n responseHeaders?: readonly ResponseHeaderSpec[];\n};\n\n/** One declared response header the runtime coerces into the envelope `headers` object. */\nexport type ResponseHeaderSpec = {\n name: string;\n key: string;\n type: 'string' | 'number' | 'boolean';\n};\n\n/** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */\nexport type QueryValue =\n | string\n | number\n | boolean\n | null\n | undefined\n | Array\n | Record;\n\n/** A credential: a literal, or a (possibly async) function resolved per request (refresh flows). */\nexport type TokenProvider = string | (() => string | Promise);\n\n/** Per-instance credentials, keyed by the scheme kinds the runtime can inject. */\nexport type AuthCredentials = {\n bearer?: TokenProvider;\n basic?: { username: string; password: string };\n apiKey?: Record;\n};\n\n/** Backoff shape: 'fixed' = constant delay; 'exponential' = doubling per attempt. */\nexport type RetryStrategy = 'fixed' | 'exponential';\n\n/**\n * The operation's identity, exposed to middleware for targeting (`ctx.operation`).\n * Generated clients instantiate the type parameters with the spec's literal unions\n * (`OperationId`/`OperationPath`/`OperationTag`) so a misspelled operation id in a\n * middleware comparison fails to compile; the string defaults keep every\n * spec-independent consumer (`runtime-contract.ts`, the runtime internals) working\n * with the base shape. `tags` stays mutable (`Tag[]`) so setup-contract types\n * (byte-locked to generated output) remain assignable through middleware callbacks.\n */\nexport type OperationContext<\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n> = { id: Id; path: Path; tags: Tag[] };\n\n/** The mutable request context threaded through the middleware chain. */\nexport type RequestContext = {\n url: string;\n method: string;\n headers: Record;\n body?: unknown;\n operation: Op;\n};\n\n/** The failed attempt handed to a custom `retryOn`: exactly one of `response`/`error` is set. */\nexport type RetryContext = {\n attempt: number;\n request: RequestContext;\n response?: Response;\n error?: unknown;\n};\n\n/** Opt-in retry policy; a per-call override merges field-by-field over the config policy. */\nexport type RetryConfig = {\n retries?: number;\n retryDelay?: number;\n retryStrategy?: RetryStrategy;\n jitter?: boolean;\n retryOn?: (ctx: RetryContext) => boolean | Promise;\n};\n\n/**\n * Structural stand-in for the runtime's ApiError so this module stays import-free\n * (pure types); the real `ApiError` class is assignable to it.\n */\nexport type ApiErrorLike = globalThis.Error & {\n url: string;\n status: number;\n statusText: string;\n body: unknown;\n};\n\n/** One interceptor: any subset of the three hooks. */\nexport type Middleware = {\n onRequest?: (ctx: RequestContext) => void | Promise;\n onResponse?: (\n response: Response,\n ctx: RequestContext\n ) => Response | void | Promise;\n /** Throw mode only: may map/replace the error. */\n // `globalThis.Error` so a spec schema named `Error` cannot shadow it in inline mode.\n onError?: (\n error: ApiErrorLike,\n ctx: RequestContext\n ) => globalThis.Error | Promise;\n};\n\n/** Client configuration: transport, defaults, retry policy, middleware, and credentials. */\nexport type ClientConfig = {\n serverUrl?: string;\n fetch?: typeof fetch;\n headers?:\n | Record\n | (() => Record | Promise>);\n retry?: RetryConfig;\n /** Milliseconds before a request attempt aborts (covers the body read too; each retry\n * attempt gets a fresh budget). Per-call `timeout` overrides it, `0` disables it.\n * SSE streams are long-lived by design and never inherit this value. */\n timeout?: number;\n /** Send an `Idempotency-Key` header on POST/PATCH (one stable key per logical call,\n * reused across retry attempts) — which also makes those retries safe under the\n * default retry policy. `true` generates a UUID per call; a function supplies the key. */\n idempotencyKey?: boolean | (() => string);\n /** Identifies this client to the API via an `X-Redocly-Client` header (the generator\n * bakes a default). Sent only OUTSIDE browsers — a custom header would force a CORS\n * preflight. Override with your own value, or `false` to disable. */\n clientHeader?: string | false;\n middleware?: Middleware[];\n auth?: AuthCredentials;\n /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */\n errorMode?: 'throw' | 'result';\n /**\n * How each call spells its inputs: `'grouped'` (the default) namespaces them by layer —\n * `{ path, query, headers, cookies, body }` — and `'flat'` takes one merged object.\n * Fixed at generate time, like `errorMode`, because it shapes the static types.\n */\n argsStyle?: 'grouped' | 'flat';\n onRequest?: Middleware['onRequest'];\n onResponse?: Middleware['onResponse'];\n onError?: Middleware['onError'];\n};\n\n/** Response readers for the per-call `parseAs` override. */\nexport type ParseAs = 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'stream';\n\n/** Per-call options: standard `RequestInit` plus a retry override, a timeout override\n * (`0` disables the config default), and a forced reader. */\nexport type RequestOptions = RequestInit & {\n retry?: RetryConfig;\n timeout?: number;\n /** Per-call idempotency key: a literal key, `true` to generate one, `false` to skip. */\n idempotencyKey?: string | boolean | (() => string);\n parseAs?: ParseAs;\n /**\n * Throw mode only: return `{ data, headers, response }` instead of the parsed body;\n * ignored in result mode. The explicit `| undefined` keeps the wrappers' emitted\n * `envelope: undefined` strip legal under `exactOptionalPropertyTypes`.\n */\n envelope?: boolean | undefined;\n};\n\n/** Throw-mode success envelope when `RequestOptions.envelope` is `true`. */\nexport type Envelope> = {\n data: TData;\n headers: THeaders;\n response: Response;\n};\n\n/** Per-call options for an SSE stream; reconnect defaults to true. */\nexport type SseOptions = RequestInit & { reconnect?: boolean; reconnectDelay?: number };\n\n/** A single decoded Server-Sent Event with its payload typed from the spec. */\nexport type ServerSentEvent = { event?: string; data: T; id?: string; retry?: number };\n\n/** Result-mode return shape: exactly one of `data`/`error` is set. */\nexport type Result =\n | { data: TData; error: undefined; response: Response }\n | { data: undefined; error: TError; response: Response };\n\n/**\n * The generated `Ops` type's shape: per-operation args/result, plus `kind: 'sse'` for\n * streams and, for paginated operations, `item` (the page's element type) and — on\n * result-mode clients only — `page` (the RAW page type `.pages()` yields, since\n * iteration unwraps the `Result` envelope the one-shot `result` carries).\n */\nexport type OpsShape = Record<\n string,\n {\n args: object;\n result: unknown;\n kind?: 'sse';\n item?: unknown;\n page?: unknown;\n /** Declared success-response headers for `{ envelope: true }` (camelCase keys). */\n headers?: object;\n /** Result-mode entries ignore the throw-only `envelope` option. */\n mode?: 'result';\n }\n>;\n\n/** The always-present client members (assigned after the operation loop — they win collisions). */\nexport type ClientCore = {\n /** Merge into the config; note `middleware` REPLACES the chain (use `use()` to compose). */\n configure(config: ClientConfig): void;\n /** Append interceptors (composes with baked/publisher middleware). */\n use(...middleware: Middleware[]): void;\n auth: {\n bearer(token: TokenProvider): void;\n basic(username: string, password: string): void;\n apiKey(scheme: string, value: TokenProvider): void;\n };\n};\n\n/**\n * The standard TypeScript optionality probe: `{}` has no required members, so\n * `{} extends A` is true exactly when every member of `A` is optional.\n */\n// oxlint-disable-next-line typescript/no-empty-object-type\ntype NoRequiredKeys = {} extends A ? true : false;\n\n/**\n * The page type `.pages()` yields: the RAW page declared by `page` (the generator\n * writes it only on result-mode paginated entries, whose `result` is the envelope),\n * or the method's own `result` (throw mode — already the raw page).\n */\ntype PageOf = Entry extends { page: unknown }\n ? Entry['page']\n : Entry['result'];\n\n/**\n * The auto-pagination members intersected onto a paginated method — present exactly when\n * the Ops entry declares `item` (the generator writes it only for paginated operations).\n * Args optionality mirrors the method's own; `unknown` otherwise (identity under `&`).\n * Iteration is error-mode-agnostic: `.pages()`/`.items()` yield raw pages/items, and a\n * failed page aborts iteration by throwing `ApiError`, even on result-mode clients; the\n * `onError` middleware hook (throw-mode-only) is not invoked.\n */\ntype Paginated = 'item' extends keyof Entry\n ? NoRequiredKeys extends true\n ? {\n pages(args?: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args?: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : {\n pages(args: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : unknown;\n\n/**\n * The stable identity every client method carries: the SPEC operationId (also set as\n * `fn.name`, but `operationId` is the explicit, minification-proof form) — a robust\n * cache key for consumer wrappers (react-query keys and the like).\n */\nexport type OperationMethodIdentity = { readonly operationId: string };\n\n/** Declared response-header bag for an Ops entry; empty object when none are declared. */\ntype HeadersOf = 'headers' extends keyof Entry\n ? NonNullable\n : Record;\n\n/**\n * Return type of a throw-mode call: the body by default, `Envelope<…>` for a literal\n * `envelope: true`, their union when the flag is a widened `boolean`. Exact\n * `RequestOptions` stays the body — pre-envelope package-mode flat sugar typed every\n * `init` parameter as `RequestOptions`, and widening that would break upgrades without\n * a regenerate. The `keyof` presence gate keeps `{ headers }` / `{ signal }` as the body\n * (`TInit['envelope']` through `TInit & RequestOptions` would otherwise be\n * `boolean | undefined`).\n */\nexport type EnvelopeResult<\n TData,\n THeaders,\n TInit extends RequestOptions | undefined,\n> = TInit extends undefined\n ? TData\n : RequestOptions extends TInit\n ? TInit extends RequestOptions\n ? TData\n : EnvelopeResultForKnownInit\n : EnvelopeResultForKnownInit;\n\ntype EnvelopeResultForKnownInit = 'envelope' extends keyof TInit\n ? [TInit['envelope' & keyof TInit]] extends [true]\n ? Envelope\n : [TInit['envelope' & keyof TInit]] extends [false | undefined]\n ? TData\n : TData | Envelope\n : TData;\n\n/** A one-shot method whose return shape never varies with per-call options. */\ntype BodyMethod =\n NoRequiredKeys extends true\n ? (args?: Entry['args'], init?: RequestOptions) => Promise\n : (args: Entry['args'], init?: RequestOptions) => Promise;\n\n/**\n * One-shot (non-SSE) method: default returns the body; `{ envelope: true }` returns\n * `{ data, headers, response }` with typed declared headers.\n */\ntype ThrowMethod =\n NoRequiredKeys extends true\n ? (\n args?: Entry['args'],\n init?: Init\n ) => Promise, Init>>\n : (\n args: Entry['args'],\n init?: Init\n ) => Promise, Init>>;\n\n/** The typed instance client: one bound method per operation plus the core members. */\nexport type Client = {\n [K in keyof Ops]: Ops[K] extends { kind: 'sse' }\n ? (NoRequiredKeys extends true\n ? (\n args?: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>\n : (\n args: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>) &\n OperationMethodIdentity\n : (Ops[K] extends { mode: 'result' } ? BodyMethod : ThrowMethod) &\n OperationMethodIdentity &\n Paginated;\n} & ClientCore;\n", 'errors.ts': "/** The error thrown (throw mode) for a non-2xx response, carrying the decoded error body. */\nexport class ApiError extends Error {\n public readonly url: string;\n public readonly status: number;\n public readonly statusText: string;\n public readonly body: unknown;\n constructor(url: string, status: number, statusText: string, body: unknown) {\n super(`Request failed with status ${status}`);\n this.name = 'ApiError';\n this.url = url;\n this.status = status;\n this.statusText = statusText;\n this.body = body;\n }\n}\n\n/** The error thrown when a request attempt exceeds the configured `timeout` — carries\n * the context a log line needs (which operation, what budget, which attempt). */\nexport class TimeoutError extends Error {\n public readonly operationId: string;\n public readonly timeout: number;\n public readonly attempt: number;\n constructor(operationId: string, timeout: number, attempt: number) {\n super(`Request \"${operationId}\" timed out after ${timeout} ms (attempt ${attempt})`);\n this.name = 'TimeoutError';\n this.operationId = operationId;\n this.timeout = timeout;\n this.attempt = attempt;\n }\n}\n\n/** The error to throw for an aborted request: the caller's abort reason when it is an Error. */\n// `globalThis.Error` (not bare `Error`) so a spec schema named `Error` cannot shadow it\n// when this module is embedded alongside generated types (inline mode).\nexport function abortError(signal: AbortSignal): globalThis.Error {\n const reason = (signal as { reason?: unknown }).reason;\n if (reason instanceof Error) return reason;\n return new DOMException('The operation was aborted.', 'AbortError');\n}\n", 'url.ts': @@ -21,9 +21,161 @@ export const RUNTIME_SOURCES = { 'sse.ts': "import { ApiError } from './errors.js';\nimport { readError } from './parse.js';\nimport { sleep } from './retry.js';\nimport { send, toHeaderRecord } from './send.js';\nimport type { ClientConfig, OperationContext, ServerSentEvent, SseOptions } from './types.js';\n\n/**\n * A frame delimiter: two consecutive line terminators (each CR, LF, or CRLF, per the SSE\n * spec — so mixed endings like `\\n\\r\\n` are valid boundaries, not just matching pairs).\n */\nconst FRAME_DELIMITER = /(?:\\r\\n|\\r|\\n){2}/;\n\n/**\n * A terminally malformed event stream — unparseable JSON `data` or an unbounded frame.\n * A stable bad payload, not a dropped connection, so the stream never reconnects on it.\n */\nexport class SseParseError extends Error {}\n\n/**\n * Consume a `text/event-stream` operation as typed events (capability module — wired\n * into `createClient`). Auto-reconnects on dropped connections, resuming from the last\n * seen event id via `Last-Event-ID` (backoff: the server's `retry:` value, then\n * `reconnectDelay`, then 1s — exponential with jitter, capped at 30s). A clean stream\n * end flushes a trailing frame and finishes; `break`/abort end the iterator cleanly.\n */\nexport async function* sse(\n config: ClientConfig,\n op: OperationContext,\n prepare: () => Promise<{ url: string; init: SseOptions; body?: unknown }>,\n dataKind: 'json' | 'text' = 'text'\n): AsyncGenerator> {\n let lastEventId: string | undefined;\n let serverRetry: number | undefined;\n let failures = 0;\n while (true) {\n // Re-prepare each attempt so a refresh-style TokenProvider yields a fresh credential\n // on reconnect (the auth is baked into `url` query + `init.headers`). `reconnect`,\n // `reconnectDelay`, and `signal` come from the caller's original options unchanged.\n const { url, init, body: requestBody } = await prepare();\n const { reconnect = true, reconnectDelay, ...rest } = init;\n const signal = rest.signal ?? undefined;\n if (signal?.aborted) return;\n const headers: Record = {\n Accept: 'text/event-stream',\n ...toHeaderRecord(rest.headers),\n };\n const sendHeaders =\n lastEventId === undefined ? headers : { ...headers, 'Last-Event-ID': lastEventId };\n try {\n const { response } = await send(\n config,\n op,\n url,\n // `timeout: 0` opts the stream out of a config-level timeout — an event stream\n // is long-lived by design and must not be severed after N milliseconds.\n { ...rest, method: rest.method ?? 'GET', headers: sendHeaders, timeout: 0 },\n requestBody,\n undefined,\n {}\n );\n if (!response.ok) {\n const errorBody = await readError(response);\n throw new ApiError(url, response.status, response.statusText, errorBody);\n }\n failures = 0;\n const body = response.body;\n if (!body) return;\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n try {\n while (true) {\n const { done, value } = await reader.read();\n buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });\n let match: RegExpExecArray | null;\n while ((match = FRAME_DELIMITER.exec(buffer)) !== null) {\n const raw = buffer.slice(0, match.index);\n buffer = buffer.slice(match.index + match[0].length);\n const event = parseSseFrame(raw, dataKind);\n if (event) {\n if (event.id !== undefined) lastEventId = event.id;\n if (event.retry !== undefined) serverRetry = event.retry;\n yield event as ServerSentEvent;\n }\n }\n if (done) {\n // Stream closed cleanly. Flush a final event that arrived without a trailing\n // delimiter, then finish — a clean end is not a dropped connection, so do not reconnect.\n const event = buffer.length > 0 ? parseSseFrame(buffer, dataKind) : undefined;\n if (event) {\n if (event.id !== undefined) lastEventId = event.id;\n if (event.retry !== undefined) serverRetry = event.retry;\n yield event as ServerSentEvent;\n }\n return;\n }\n // Bound memory: a server that never sends a frame delimiter would otherwise\n // grow `buffer` without limit. 1 MiB is far above any real SSE frame.\n if (buffer.length > 1048576) {\n throw new SseParseError('SSE frame exceeded 1048576 characters without a delimiter');\n }\n }\n } finally {\n await reader.cancel().catch(() => undefined);\n }\n } catch (error) {\n if (signal?.aborted) return;\n // A non-OK HTTP response (4xx/5xx) or an unparseable JSON payload is a definitive\n // error, not a transient drop — surface it instead of reconnecting in a loop (a\n // stable bad payload would otherwise reconnect forever).\n if (error instanceof ApiError || error instanceof SseParseError) throw error;\n // A transport failure (connect/DNS/reset) when opening the request, or a mid-stream\n // read error, is a dropped connection: fall through to backoff/reconnect when enabled.\n if (!reconnect) throw error;\n }\n // Only the swallowed-drop path reaches here: reconnect is on and the signal not aborted.\n failures++;\n const base = serverRetry ?? reconnectDelay ?? 1000;\n const delay = Math.min(base * Math.pow(2, failures - 1), 30_000);\n try {\n await sleep(Math.random() * delay, signal);\n } catch {\n return; // sleep rejects only on abort — end the iterator cleanly\n }\n }\n}\n\n/** Parse one raw SSE frame (its lines) into an event; returns undefined for comment-only frames. */\nexport function parseSseFrame(\n raw: string,\n dataKind: 'json' | 'text'\n): ServerSentEvent | undefined {\n let event: string | undefined;\n const dataLines: string[] = [];\n let id: string | undefined;\n let retry: number | undefined;\n let sawField = false;\n for (const line of raw.split(/\\r\\n|\\n|\\r/)) {\n if (line === '' || line.startsWith(':')) continue;\n const colon = line.indexOf(':');\n const field = colon === -1 ? line : line.slice(0, colon);\n let val = colon === -1 ? '' : line.slice(colon + 1);\n if (val.startsWith(' ')) val = val.slice(1);\n sawField = true;\n if (field === 'event') event = val;\n else if (field === 'data') dataLines.push(val);\n else if (field === 'id') id = val;\n else if (field === 'retry') {\n // ASCII digits only, per the EventSource spec — anything else is ignored\n // (`Number('')` is 0 and would zero the reconnect backoff).\n if (/^\\d+$/.test(val)) retry = Number(val);\n }\n }\n if (!sawField) return undefined;\n const dataText = dataLines.join('\\n');\n let data: unknown = dataText;\n if (dataKind === 'json' && dataText !== '') {\n try {\n data = JSON.parse(dataText);\n } catch (error) {\n throw new SseParseError(\n `Failed to parse SSE event data as JSON: ${error instanceof Error ? error.message : String(error)}`\n );\n }\n }\n return { event, data, id, retry };\n}\n", 'create-client.ts': - "import { ApiError } from './errors.js';\nimport { parse, readError } from './parse.js';\nimport { middlewareChain, send, toHeaderRecord, type SendCapabilities } from './send.js';\nimport type {\n ApiErrorLike,\n Client,\n ClientConfig,\n Middleware,\n OperationContext,\n OperationDescriptor,\n OpsShape,\n PaginationSpec,\n ParseAs,\n QueryValue,\n RequestOptions,\n ResponseHeaderSpec,\n SecuritySpec,\n ServerSentEvent,\n SseOptions,\n TokenProvider,\n} from './types.js';\nimport { buildUrl, substitutePath, type QueryStyle } from './url.js';\n\n/**\n * The optional behaviors `createClientCore` can dispatch to but never statically\n * imports. The package's public `createClient` wires the full set; the future\n * inline-mode assembler wires only the capabilities a spec needs.\n */\nexport type Capabilities = SendCapabilities & {\n resolveAuth?: (\n security: readonly (readonly SecuritySpec[])[],\n config: ClientConfig\n ) => Promise<{ headers: Record; query: Record }>;\n sse?: (\n config: ClientConfig,\n op: OperationContext,\n // Re-preparing per (re)connect (not a frozen url/init) lets a refresh-style\n // TokenProvider issue a fresh credential after a dropped stream reconnects.\n prepare: () => Promise<{ url: string; init: SseOptions }>,\n dataKind: 'json' | 'text'\n ) => AsyncGenerator>;\n paginate?: {\n pages: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n items: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n // The `link`-style iterators need the raw `Link` header + page URL, which the\n // parsed-page call above cannot carry (the shape mirrors paginate's `LinkPageCall`).\n pagesByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n itemsByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n };\n};\n\n/** The grouped args wire shape: path params by name plus the `params`/`body`/`headers`/`cookies` slots. */\nexport type OperationArgs = {\n params?: Record;\n body?: unknown;\n headers?: Record;\n cookies?: Record;\n} & Record;\n\n/** The response reader implied by the descriptor (before any per-call `parseAs` override). */\n/**\n * The `Accept` header matching how the response will be read — a blob/text operation\n * must not ask for `application/json` (a content-negotiating server would 406 or\n * answer with a JSON error body instead of the payload). Caller `init.headers` and\n * `config.headers` still override.\n */\nfunction acceptFor(kind: ParseAs | 'void'): string {\n if (kind === 'text') return 'text/*';\n if (kind === 'blob' || kind === 'arrayBuffer' || kind === 'stream' || kind === 'formData') {\n return '*/*';\n }\n return 'application/json'; // json | auto | void\n}\n\nfunction kindFor(op: OperationDescriptor): ParseAs | 'void' {\n if (op.responseKind === 'void' || op.responseKind === 'blob' || op.responseKind === 'text') {\n return op.responseKind;\n }\n return 'auto';\n}\n\n/** Route the grouped args by the descriptor: path values, query object, body, extra headers, cookies. */\nfunction splitArgs(op: OperationDescriptor, args: OperationArgs) {\n const path: Record = {};\n const pathNames = new Set();\n for (const param of op.params ?? []) {\n if (param.in === 'path') {\n pathNames.add(param.name);\n path[param.name] = args[param.name];\n }\n }\n // An unknown top-level key can only be a bug (usually a flat-style call shape passed\n // to a grouped client: `{ limit: 10 }` instead of `{ params: { limit: 10 } }`).\n // TypeScript catches it, but transpilers that skip type-checking would otherwise\n // ship a request that silently drops the value — fail the call loudly instead.\n for (const key of Object.keys(args)) {\n if (key === 'params' || key === 'body' || key === 'headers' || key === 'cookies') continue;\n if (pathNames.has(key)) continue;\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\". Query parameters go under params: { … } and the request body under body; valid keys are params, body, headers, cookies` +\n (pathNames.size > 0 ? `, and the path parameters (${[...pathNames].join(', ')}).` : '.')\n );\n }\n return {\n path,\n query: args.params,\n body: args.body,\n headers: args.headers,\n cookies: args.cookies,\n };\n}\n\n/**\n * The query-serialization hints for the descriptor's query params. A spec is built only\n * when the param deviates from the OpenAPI defaults (`form` + `explode: true`, encoded),\n * and always fully resolved — so `explode: false` or `allowReserved` alone (no `style`)\n * are honored, and an omitted `explode` keeps the exploded default.\n */\nfunction queryStyles(op: OperationDescriptor): Record | undefined {\n let styles: Record | undefined;\n for (const param of op.params ?? []) {\n if (param.in !== 'query') continue;\n const deviates =\n (param.style !== undefined && param.style !== 'form') ||\n param.explode === false ||\n param.allowReserved === true;\n if (!deviates) continue;\n styles ??= {};\n styles[param.name] = {\n style: param.style ?? 'form',\n explode: param.explode ?? true,\n allowReserved: param.allowReserved,\n };\n }\n return styles;\n}\n\n/** Stringify caller-supplied extra headers, skipping empty entries. */\nfunction stringHeaders(headers: Record | undefined): Record {\n const out: Record = {};\n for (const [key, value] of Object.entries(headers ?? {})) {\n if (value !== undefined && value !== null) out[key] = String(value);\n }\n return out;\n}\n\n/** Build the request pieces an attempt needs: the final URL and the merged per-call init. */\nasync function prepareRequest(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions | SseOptions,\n caps: Capabilities\n): Promise<{ url: string; init: RequestOptions; body: unknown }> {\n const { path, query, body, headers, cookies } = splitArgs(op, args);\n const authed: { headers: Record; query: Record } =\n op.security?.length && caps.resolveAuth\n ? await caps.resolveAuth(op.security, config)\n : { headers: {}, query: {} };\n // Cookie params join the auth-injected cookies in one `Cookie` header (values\n // percent-encoded, like auth cookies). Server-side only — browsers own the header.\n const cookiePairs = Object.entries(cookies ?? {})\n .filter(([, value]) => value !== undefined && value !== null)\n .map(([cookieName, value]) => `${cookieName}=${encodeURIComponent(String(value))}`);\n if (cookiePairs.length > 0) {\n authed.headers.Cookie = [authed.headers.Cookie, ...cookiePairs].filter(Boolean).join('; ');\n }\n const fullQuery: Record = { ...query, ...authed.query };\n const url = buildUrl(\n config.serverUrl ?? '',\n substitutePath(op.path, path),\n Object.keys(fullQuery).length > 0 ? fullQuery : undefined,\n queryStyles(op)\n );\n const mergedInit: RequestOptions = {\n ...init,\n method: op.method.toUpperCase(),\n // Precedence, lowest → highest (later spreads win): injected auth → explicit\n // header params → caller `init.headers` — the caller always overrides both.\n headers: {\n ...authed.headers,\n ...stringHeaders(headers),\n ...toHeaderRecord(init.headers),\n },\n };\n return { url, init: mergedInit, body };\n}\n\n/** Coerce a single declared response header value; omit when absent or unparsable. */\nfunction coerceResponseHeader(\n raw: string | null,\n type: ResponseHeaderSpec['type']\n): string | number | boolean | undefined {\n if (raw === null) return undefined;\n if (type === 'number') {\n if (raw.trim() === '') return undefined;\n const value = Number(raw);\n return Number.isFinite(value) ? value : undefined;\n }\n if (type === 'boolean') {\n const value = raw.trim().toLowerCase();\n if (value === 'true') return true;\n if (value === 'false') return false;\n return undefined;\n }\n return raw;\n}\n\n/** Build the camelCase declared-header bag for a throw-mode envelope. */\nfunction readEnvelopeHeaders(\n response: Response,\n specs: readonly ResponseHeaderSpec[] | undefined\n): Record {\n const headers: Record = {};\n for (const spec of specs ?? []) {\n const value = coerceResponseHeader(response.headers.get(spec.name), spec.type);\n if (value !== undefined) headers[spec.key] = value;\n }\n return headers;\n}\n\n/** One non-SSE call: send, then branch on the configured error mode. */\nasync function execute(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions,\n caps: Capabilities\n): Promise {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // `parseAs` / `envelope` are client options, not fetch RequestInit fields.\n const { parseAs, envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const { response, context } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (config.errorMode === 'result') {\n if (!response.ok) {\n return { data: undefined, error: await readError(response), response };\n }\n return { data: await parse(response, readKind), error: undefined, response };\n }\n if (!response.ok) {\n let error: globalThis.Error = new ApiError(\n context.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n // Thread the error through each middleware's onError in turn (each may replace it).\n for (const mw of middlewareChain(config)) {\n if (mw.onError) error = await mw.onError(error as ApiErrorLike, context);\n }\n throw error;\n }\n const data = await parse(response, readKind);\n if (envelope === true) {\n return {\n data,\n headers: readEnvelopeHeaders(response, op.responseHeaders),\n response,\n };\n }\n return data;\n}\n\n/** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */\nfunction paginateCapability(caps: Capabilities, op: OperationDescriptor) {\n if (!caps.paginate) {\n throw new Error(`Pagination capability not wired: cannot iterate operation \"${op.id}\"`);\n }\n return caps.paginate;\n}\n\n/**\n * The per-page call the iterators drive: the method itself in throw mode; in result\n * mode a wrapper that unwraps the `{ data, error, response }` envelope — the page\n * pointers are data-rooted — rethrowing a failed page as `ApiError` (iteration is\n * error-mode-agnostic; the throw-mode-only `onError` middleware hook is not invoked).\n */\nfunction pageCall(\n method: (args?: OperationArgs, init?: RequestOptions) => Promise,\n config: ClientConfig\n) {\n const callWithoutEnvelope = (args?: OperationArgs, init?: RequestOptions) => {\n if (!init || init.envelope === undefined) return method(args, init);\n const { envelope: _envelope, ...pageInit } = init;\n return method(args, pageInit);\n };\n if (config.errorMode !== 'result') return callWithoutEnvelope;\n return async (args?: OperationArgs, init?: RequestOptions) => {\n const envelope = (await callWithoutEnvelope(args, init)) as {\n data: unknown;\n error: unknown;\n response: Response;\n };\n // Failure is `!response.ok` — NOT `data === undefined`: a successful bodyless page\n // (204/void) also parses to undefined data, and a failed page's `error` can be\n // undefined too (unreadable body). The pointers then miss on the undefined data\n // and iteration stops cleanly, which is the correct semantics for an empty page.\n if (!envelope.response.ok) {\n const { response } = envelope;\n throw new ApiError(response.url, response.status, response.statusText, envelope.error);\n }\n return envelope.data;\n };\n}\n\n/**\n * The per-page call the `link`-style iterators drive: like `execute`, but returning the\n * parsed page together with the raw `Link` header and the page's own URL (for resolving\n * a relative `rel=\"next\"` target). Error-mode-agnostic like all iteration: a failed\n * page throws `ApiError` even on result-mode clients.\n */\nfunction linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) {\n return async (args: OperationArgs = {}, init: RequestOptions = {}) => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const { parseAs, envelope: _envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { response } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (!response.ok) {\n throw new ApiError(\n prepared.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n }\n return {\n page: await parse(response, readKind),\n linkHeader: response.headers.get('link'),\n // Some `Response` implementations leave `url` empty (mocks, constructed responses).\n url: response.url === '' ? prepared.url : response.url,\n };\n };\n}\n\n/**\n * Build a typed instance client over operation descriptors: one real bound method per\n * operation (attached by a construction-time loop — no Proxy), plus the core members\n * (`configure`/`use`/`auth`), which are assigned AFTER the loop so they win any name\n * collision with an operation. All behavior dispatches through the capability seam.\n */\nexport function createClientCore<\n Ops extends OpsShape,\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n>(\n operations: Record,\n initial: ClientConfig> = {},\n caps: Capabilities = {}\n): Client> {\n // The literal-union narrowing is a compile-time DX contract only; internally the\n // runtime works with the base (string-typed) context. One cast at this boundary —\n // `ClientConfig` is not assignable to `ClientConfig` (middleware ctx\n // params are contravariant).\n const given = initial as ClientConfig;\n // Private mutable config; the middleware array is copied so `use()` never mutates the caller's.\n const config: ClientConfig = { ...given, middleware: [...(given.middleware ?? [])] };\n const client = {} as Record;\n\n for (const [name, op] of Object.entries(operations)) {\n if (op.responseKind === 'sse') {\n const method = (args: OperationArgs = {}, init: SseOptions = {}) => {\n if (!caps.sse) {\n throw new Error(`SSE capability not wired: cannot stream operation \"${op.id}\"`);\n }\n const stream = caps.sse;\n return (async function* () {\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // A thunk the stream re-runs on every (re)connect, so auth (which `prepareRequest`\n // resolves) is refreshed per attempt rather than frozen at the first connect.\n const prepare = async () => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n return { url: prepared.url, init: prepared.init as SseOptions, body: prepared.body };\n };\n yield* stream(config, opCtx, prepare, op.sseDataKind ?? 'text');\n })();\n };\n // Consumers key off the function reference (cache keys, `OPERATIONS[fn.name]`), so\n // each closure carries its operationId instead of an inferred binding name.\n // `operationId` is the explicit, minification-proof form of the same identity\n // (the SPEC operationId — `name` is the emitted key, which a collision may rename).\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n client[name] = method;\n } else {\n const method = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n execute(config, op, args, init, caps);\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n const spec = op.pagination;\n // Paginated ops keep their one-shot call and gain `.pages`/`.items`, dispatching\n // through the capability seam (like SSE: absent capability throws descriptively).\n // Iteration is error-mode-agnostic: the iterators' pointers are data-rooted, so on\n // a result-mode client (`errorMode` is fixed at construction — `configure()`\n // ignores it) each page's envelope is unwrapped before it reaches the capability.\n // A failed page aborts iteration by throwing ApiError, even on result-mode\n // clients; the `onError` middleware hook (throw-mode-only) is not invoked.\n client[name] =\n spec === undefined\n ? method\n : spec.style === 'link'\n ? Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pagesByLink(\n linkPageCall(config, op, caps),\n args,\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).itemsByLink(\n linkPageCall(config, op, caps),\n spec,\n args,\n init\n ),\n })\n : Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pages(pageCall(method, config), spec, args, init),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).items(pageCall(method, config), spec, args, init),\n });\n }\n }\n\n // Core members are assigned AFTER the operation loop — they win over colliding op names.\n client.configure = (next: ClientConfig): void => {\n // `errorMode` is fixed at generate time (it shapes the static types); flipping it at\n // runtime would silently desync return shapes from `Client`, so it is ignored.\n const { errorMode: _fixed, auth, ...rest } = next;\n Object.assign(config, rest);\n // `auth` merges into existing credentials (like the `auth.*` setters) rather than\n // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set\n // basic/apiKey. `apiKey` merges per scheme.\n if (auth) {\n config.auth = {\n ...config.auth,\n ...auth,\n ...(auth.apiKey ? { apiKey: { ...config.auth?.apiKey, ...auth.apiKey } } : {}),\n };\n }\n };\n client.use = (...middleware: Middleware[]): void => {\n // Reassign (don't push) so a caller-provided `middleware` array isn't mutated.\n config.middleware = [...(config.middleware ?? []), ...middleware];\n };\n client.auth = {\n bearer(token: TokenProvider): void {\n config.auth = { ...config.auth, bearer: token };\n },\n basic(username: string, password: string): void {\n config.auth = { ...config.auth, basic: { username, password } };\n },\n apiKey(scheme: string, value: TokenProvider): void {\n config.auth = { ...config.auth, apiKey: { ...config.auth?.apiKey, [scheme]: value } };\n },\n };\n\n return client as Client>;\n}\n", + "import { ApiError } from './errors.js';\nimport { parse, readError } from './parse.js';\nimport { middlewareChain, send, toHeaderRecord, type SendCapabilities } from './send.js';\nimport type {\n ApiErrorLike,\n Client,\n ClientConfig,\n Middleware,\n OperationContext,\n OperationDescriptor,\n OpsShape,\n PaginationSpec,\n ParseAs,\n QueryValue,\n RequestOptions,\n ResponseHeaderSpec,\n SecuritySpec,\n ServerSentEvent,\n SseOptions,\n TokenProvider,\n} from './types.js';\nimport { buildUrl, substitutePath, type QueryStyle } from './url.js';\n\n/**\n * The optional behaviors `createClientCore` can dispatch to but never statically\n * imports. The package's public `createClient` wires the full set; the future\n * inline-mode assembler wires only the capabilities a spec needs.\n */\nexport type Capabilities = SendCapabilities & {\n resolveAuth?: (\n security: readonly (readonly SecuritySpec[])[],\n config: ClientConfig\n ) => Promise<{ headers: Record; query: Record }>;\n sse?: (\n config: ClientConfig,\n op: OperationContext,\n // Re-preparing per (re)connect (not a frozen url/init) lets a refresh-style\n // TokenProvider issue a fresh credential after a dropped stream reconnects.\n prepare: () => Promise<{ url: string; init: SseOptions }>,\n dataKind: 'json' | 'text'\n ) => AsyncGenerator>;\n paginate?: {\n pages: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n items: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n // The `link`-style iterators need the raw `Link` header + page URL, which the\n // parsed-page call above cannot carry (the shape mirrors paginate's `LinkPageCall`).\n pagesByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n itemsByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n };\n};\n\n/**\n * One call's inputs, namespaced by transport layer. `argsStyle: 'flat'` clients accept the\n * merged form instead (every parameter and body property at one level) — `namespaceArgs`\n * converts it to this shape before anything downstream reads it.\n */\nexport type OperationArgs = {\n path?: Record;\n query?: Record;\n body?: unknown;\n headers?: Record;\n cookies?: Record;\n} & Record;\n\n/** The five layer keys, and the only top-level keys a namespaced call may carry. */\nconst LAYERS: readonly string[] = ['path', 'query', 'body', 'headers', 'cookies'];\n\n/** Where a declared parameter's `in` value puts it. */\nconst LAYER_OF: Record = {\n path: 'path',\n query: 'query',\n header: 'headers',\n cookie: 'cookies',\n};\n\n/**\n * Merged (`argsStyle: 'flat'`) args → the namespaced shape. A key that names a declared\n * parameter goes to that parameter's layer; anything else is a property of the request\n * body, which is how a flat call spells an object body. `body` stays reserved for the\n * operations a flat call cannot merge (an array, a scalar, or a binary body).\n */\nfunction namespaceArgs(op: OperationDescriptor, args: OperationArgs): OperationArgs {\n const layers: Record> = {};\n let body: unknown;\n let properties: Record | undefined;\n const layerOfParam = new Map((op.params ?? []).map((param) => [param.name, param.in]));\n for (const [key, value] of Object.entries(args)) {\n const layer = LAYER_OF[layerOfParam.get(key) ?? ''];\n if (layer !== undefined) {\n (layers[layer] ??= {})[key] = value;\n } else if (key === 'body' && op.body !== undefined) {\n body = value;\n } else if (op.body !== undefined) {\n (properties ??= {})[key] = value;\n } else {\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\": it names no declared parameter, and the operation takes no request body.`\n );\n }\n }\n const namespaced: OperationArgs = {};\n if (layers.path) namespaced.path = layers.path;\n // The flat surface types every query value, so the collected bag is one by construction.\n if (layers.query) namespaced.query = layers.query as Record;\n if (layers.headers) namespaced.headers = layers.headers;\n if (layers.cookies) namespaced.cookies = layers.cookies;\n if (properties !== undefined) namespaced.body = properties;\n else if (body !== undefined) namespaced.body = body;\n return namespaced;\n}\n\n/** The response reader implied by the descriptor (before any per-call `parseAs` override). */\n/**\n * The `Accept` header matching how the response will be read — a blob/text operation\n * must not ask for `application/json` (a content-negotiating server would 406 or\n * answer with a JSON error body instead of the payload). Caller `init.headers` and\n * `config.headers` still override.\n */\nfunction acceptFor(kind: ParseAs | 'void'): string {\n if (kind === 'text') return 'text/*';\n if (kind === 'blob' || kind === 'arrayBuffer' || kind === 'stream' || kind === 'formData') {\n return '*/*';\n }\n return 'application/json'; // json | auto | void\n}\n\nfunction kindFor(op: OperationDescriptor): ParseAs | 'void' {\n if (op.responseKind === 'void' || op.responseKind === 'blob' || op.responseKind === 'text') {\n return op.responseKind;\n }\n return 'auto';\n}\n\n/**\n * The call's inputs in namespaced form, converting first on a flat-style client. An\n * operation the generator marked `argsStyle: 'grouped'` is already namespaced — its names\n * could not be merged, so its input type never offered the flat shape.\n */\nfunction inputOf(\n op: OperationDescriptor,\n args: OperationArgs,\n config: ClientConfig\n): OperationArgs {\n const merged = config.argsStyle === 'flat' && op.argsStyle !== 'grouped';\n return merged ? namespaceArgs(op, args) : args;\n}\n\n/** Route the namespaced args to the request pieces. */\nfunction splitArgs(op: OperationDescriptor, args: OperationArgs) {\n // An unknown layer key can only be a bug (usually flat-style args on a namespaced\n // client). TypeScript catches it, but a transpiler that skips type-checking would\n // otherwise ship a request that silently drops the value — fail the call loudly.\n for (const key of Object.keys(args)) {\n if (!LAYERS.includes(key)) {\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\". Inputs are grouped by layer: ${LAYERS.join(', ')}.`\n );\n }\n }\n return {\n path: args.path ?? {},\n query: args.query,\n body: args.body,\n headers: args.headers,\n cookies: args.cookies,\n };\n}\n\n/**\n * The query-serialization hints for the descriptor's query params. A spec is built only\n * when the param deviates from the OpenAPI defaults (`form` + `explode: true`, encoded),\n * and always fully resolved — so `explode: false` or `allowReserved` alone (no `style`)\n * are honored, and an omitted `explode` keeps the exploded default.\n */\nfunction queryStyles(op: OperationDescriptor): Record | undefined {\n let styles: Record | undefined;\n for (const param of op.params ?? []) {\n if (param.in !== 'query') continue;\n const deviates =\n (param.style !== undefined && param.style !== 'form') ||\n param.explode === false ||\n param.allowReserved === true;\n if (!deviates) continue;\n styles ??= {};\n styles[param.name] = {\n style: param.style ?? 'form',\n explode: param.explode ?? true,\n allowReserved: param.allowReserved,\n };\n }\n return styles;\n}\n\n/** Stringify caller-supplied extra headers, skipping empty entries. */\nfunction stringHeaders(headers: Record | undefined): Record {\n const out: Record = {};\n for (const [key, value] of Object.entries(headers ?? {})) {\n if (value !== undefined && value !== null) out[key] = String(value);\n }\n return out;\n}\n\n/** Build the request pieces an attempt needs: the final URL and the merged per-call init. */\nasync function prepareRequest(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions | SseOptions,\n caps: Capabilities\n): Promise<{ url: string; init: RequestOptions; body: unknown }> {\n const { path, query, body, headers, cookies } = splitArgs(op, args);\n const authed: { headers: Record; query: Record } =\n op.security?.length && caps.resolveAuth\n ? await caps.resolveAuth(op.security, config)\n : { headers: {}, query: {} };\n // Cookie params join the auth-injected cookies in one `Cookie` header (values\n // percent-encoded, like auth cookies). Server-side only — browsers own the header.\n const cookiePairs = Object.entries(cookies ?? {})\n .filter(([, value]) => value !== undefined && value !== null)\n .map(([cookieName, value]) => `${cookieName}=${encodeURIComponent(String(value))}`);\n if (cookiePairs.length > 0) {\n authed.headers.Cookie = [authed.headers.Cookie, ...cookiePairs].filter(Boolean).join('; ');\n }\n const fullQuery: Record = { ...query, ...authed.query };\n const url = buildUrl(\n config.serverUrl ?? '',\n substitutePath(op.path, path),\n Object.keys(fullQuery).length > 0 ? fullQuery : undefined,\n queryStyles(op)\n );\n const mergedInit: RequestOptions = {\n ...init,\n method: op.method.toUpperCase(),\n // Precedence, lowest → highest (later spreads win): injected auth → explicit\n // header params → caller `init.headers` — the caller always overrides both.\n headers: {\n ...authed.headers,\n ...stringHeaders(headers),\n ...toHeaderRecord(init.headers),\n },\n };\n return { url, init: mergedInit, body };\n}\n\n/** Coerce a single declared response header value; omit when absent or unparsable. */\nfunction coerceResponseHeader(\n raw: string | null,\n type: ResponseHeaderSpec['type']\n): string | number | boolean | undefined {\n if (raw === null) return undefined;\n if (type === 'number') {\n if (raw.trim() === '') return undefined;\n const value = Number(raw);\n return Number.isFinite(value) ? value : undefined;\n }\n if (type === 'boolean') {\n const value = raw.trim().toLowerCase();\n if (value === 'true') return true;\n if (value === 'false') return false;\n return undefined;\n }\n return raw;\n}\n\n/** Build the camelCase declared-header bag for a throw-mode envelope. */\nfunction readEnvelopeHeaders(\n response: Response,\n specs: readonly ResponseHeaderSpec[] | undefined\n): Record {\n const headers: Record = {};\n for (const spec of specs ?? []) {\n const value = coerceResponseHeader(response.headers.get(spec.name), spec.type);\n if (value !== undefined) headers[spec.key] = value;\n }\n return headers;\n}\n\n/** One non-SSE call: send, then branch on the configured error mode. */\nasync function execute(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions,\n caps: Capabilities\n): Promise {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // `parseAs` / `envelope` are client options, not fetch RequestInit fields.\n const { parseAs, envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const { response, context } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (config.errorMode === 'result') {\n if (!response.ok) {\n return { data: undefined, error: await readError(response), response };\n }\n return { data: await parse(response, readKind), error: undefined, response };\n }\n if (!response.ok) {\n let error: globalThis.Error = new ApiError(\n context.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n // Thread the error through each middleware's onError in turn (each may replace it).\n for (const mw of middlewareChain(config)) {\n if (mw.onError) error = await mw.onError(error as ApiErrorLike, context);\n }\n throw error;\n }\n const data = await parse(response, readKind);\n if (envelope === true) {\n return {\n data,\n headers: readEnvelopeHeaders(response, op.responseHeaders),\n response,\n };\n }\n return data;\n}\n\n/** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */\nfunction paginateCapability(caps: Capabilities, op: OperationDescriptor) {\n if (!caps.paginate) {\n throw new Error(`Pagination capability not wired: cannot iterate operation \"${op.id}\"`);\n }\n return caps.paginate;\n}\n\n/**\n * The per-page call the iterators drive: the method itself in throw mode; in result\n * mode a wrapper that unwraps the `{ data, error, response }` envelope — the page\n * pointers are data-rooted — rethrowing a failed page as `ApiError` (iteration is\n * error-mode-agnostic; the throw-mode-only `onError` middleware hook is not invoked).\n */\nfunction pageCall(\n method: (args?: OperationArgs, init?: RequestOptions) => Promise,\n config: ClientConfig\n) {\n const callWithoutEnvelope = (args?: OperationArgs, init?: RequestOptions) => {\n if (!init || init.envelope === undefined) return method(args, init);\n const { envelope: _envelope, ...pageInit } = init;\n return method(args, pageInit);\n };\n if (config.errorMode !== 'result') return callWithoutEnvelope;\n return async (args?: OperationArgs, init?: RequestOptions) => {\n const envelope = (await callWithoutEnvelope(args, init)) as {\n data: unknown;\n error: unknown;\n response: Response;\n };\n // Failure is `!response.ok` — NOT `data === undefined`: a successful bodyless page\n // (204/void) also parses to undefined data, and a failed page's `error` can be\n // undefined too (unreadable body). The pointers then miss on the undefined data\n // and iteration stops cleanly, which is the correct semantics for an empty page.\n if (!envelope.response.ok) {\n const { response } = envelope;\n throw new ApiError(response.url, response.status, response.statusText, envelope.error);\n }\n return envelope.data;\n };\n}\n\n/**\n * The per-page call the `link`-style iterators drive: like `execute`, but returning the\n * parsed page together with the raw `Link` header and the page's own URL (for resolving\n * a relative `rel=\"next\"` target). Error-mode-agnostic like all iteration: a failed\n * page throws `ApiError` even on result-mode clients.\n */\nfunction linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) {\n return async (args: OperationArgs = {}, init: RequestOptions = {}) => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const { parseAs, envelope: _envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { response } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (!response.ok) {\n throw new ApiError(\n prepared.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n }\n return {\n page: await parse(response, readKind),\n linkHeader: response.headers.get('link'),\n // Some `Response` implementations leave `url` empty (mocks, constructed responses).\n url: response.url === '' ? prepared.url : response.url,\n };\n };\n}\n\n/**\n * Build a typed instance client over operation descriptors: one real bound method per\n * operation (attached by a construction-time loop — no Proxy), plus the core members\n * (`configure`/`use`/`auth`), which are assigned AFTER the loop so they win any name\n * collision with an operation. All behavior dispatches through the capability seam.\n */\nexport function createClientCore<\n Ops extends OpsShape,\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n>(\n operations: Record,\n initial: ClientConfig> = {},\n caps: Capabilities = {}\n): Client> {\n // The literal-union narrowing is a compile-time DX contract only; internally the\n // runtime works with the base (string-typed) context. One cast at this boundary —\n // `ClientConfig` is not assignable to `ClientConfig` (middleware ctx\n // params are contravariant).\n const given = initial as ClientConfig;\n // Private mutable config; the middleware array is copied so `use()` never mutates the caller's.\n const config: ClientConfig = { ...given, middleware: [...(given.middleware ?? [])] };\n const client = {} as Record;\n\n for (const [name, op] of Object.entries(operations)) {\n if (op.responseKind === 'sse') {\n const method = (given: OperationArgs = {}, init: SseOptions = {}) => {\n const args = inputOf(op, given, config);\n if (!caps.sse) {\n throw new Error(`SSE capability not wired: cannot stream operation \"${op.id}\"`);\n }\n const stream = caps.sse;\n return (async function* () {\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // A thunk the stream re-runs on every (re)connect, so auth (which `prepareRequest`\n // resolves) is refreshed per attempt rather than frozen at the first connect.\n const prepare = async () => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n return { url: prepared.url, init: prepared.init as SseOptions, body: prepared.body };\n };\n yield* stream(config, opCtx, prepare, op.sseDataKind ?? 'text');\n })();\n };\n // Consumers key off the function reference (cache keys, `OPERATIONS[fn.name]`), so\n // each closure carries its operationId instead of an inferred binding name.\n // `operationId` is the explicit, minification-proof form of the same identity\n // (the SPEC operationId — `name` is the emitted key, which a collision may rename).\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n client[name] = method;\n } else {\n // `raw` takes namespaced args; `method` is the public entry that accepts whichever\n // style the client was generated with. The iterators namespace once and then drive\n // `raw`, so a flat call is never converted twice.\n const raw = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n execute(config, op, args, init, caps);\n const method = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n raw(inputOf(op, args, config), init);\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n const spec = op.pagination;\n // Paginated ops keep their one-shot call and gain `.pages`/`.items`, dispatching\n // through the capability seam (like SSE: absent capability throws descriptively).\n // Iteration is error-mode-agnostic: the iterators' pointers are data-rooted, so on\n // a result-mode client (`errorMode` is fixed at construction — `configure()`\n // ignores it) each page's envelope is unwrapped before it reaches the capability.\n // A failed page aborts iteration by throwing ApiError, even on result-mode\n // clients; the `onError` middleware hook (throw-mode-only) is not invoked.\n client[name] =\n spec === undefined\n ? method\n : spec.style === 'link'\n ? Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pagesByLink(\n linkPageCall(config, op, caps),\n inputOf(op, args ?? {}, config),\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).itemsByLink(\n linkPageCall(config, op, caps),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n })\n : Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pages(\n pageCall(raw, config),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).items(\n pageCall(raw, config),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n });\n }\n }\n\n // Core members are assigned AFTER the operation loop — they win over colliding op names.\n client.configure = (next: ClientConfig): void => {\n // `errorMode` and `argsStyle` are fixed at generate time (they shape the static types);\n // flipping either at runtime would silently desync the calls from `Client`, so both\n // are ignored here.\n const { errorMode: _fixedMode, argsStyle: _fixedStyle, auth, ...rest } = next;\n Object.assign(config, rest);\n // `auth` merges into existing credentials (like the `auth.*` setters) rather than\n // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set\n // basic/apiKey. `apiKey` merges per scheme.\n if (auth) {\n config.auth = {\n ...config.auth,\n ...auth,\n ...(auth.apiKey ? { apiKey: { ...config.auth?.apiKey, ...auth.apiKey } } : {}),\n };\n }\n };\n client.use = (...middleware: Middleware[]): void => {\n // Reassign (don't push) so a caller-provided `middleware` array isn't mutated.\n config.middleware = [...(config.middleware ?? []), ...middleware];\n };\n client.auth = {\n bearer(token: TokenProvider): void {\n config.auth = { ...config.auth, bearer: token };\n },\n basic(username: string, password: string): void {\n config.auth = { ...config.auth, basic: { username, password } };\n },\n apiKey(scheme: string, value: TokenProvider): void {\n config.auth = { ...config.auth, apiKey: { ...config.auth?.apiKey, [scheme]: value } };\n },\n };\n\n return client as Client>;\n}\n", 'paginate.ts': - "import type { OperationArgs } from './create-client.js';\nimport type { PaginationSpec, QueryValue, RequestOptions } from './types.js';\n\n/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nexport function resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nexport async function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nexport async function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\nexport type LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nexport function linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nexport async function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nexport async function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n", + "import type { OperationArgs } from './create-client.js';\nimport type { PaginationSpec, QueryValue, RequestOptions } from './types.js';\n\n/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `query` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nexport function resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `query[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nexport async function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.query?.[spec.param];\n while (true) {\n const query = { ...args.query };\n if (cursor !== undefined) query[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, query }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `query[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.query?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call({ ...args, query: { ...args.query, [spec.param]: position } }, init);\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nexport async function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\nexport type LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nexport function linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nexport async function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let query = args.query;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, query }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n query = { ...args.query, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nexport async function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n", + 'cli.ts': + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /**\n * Present when the operation takes a JSON request body. `merged` marks a body whose own\n * properties a flat-style call spells at the top level (the generator decides this from\n * the schema, so the CLI and the client can never disagree).\n */\n body?: { required: boolean; merged?: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n /** `'grouped'` marks a command whose client method takes namespaced inputs even on a\n * flat-style client, because its merged names would collide. */\n argsStyle?: 'grouped';\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n /** The name the CLI is invoked as, for help output only. The generated entry reads it\n * from `process.argv[1]`, so help never names a command that is not installed. */\n name: string;\n /** Credential variable prefix, constant-cased: `CAFE` gives `CAFE_TOKEN`. Fixed at\n * generation from the output file name, so renaming the binary keeps the variables\n * a published CLI already documents. A composed entry sets one per api alias. */\n envPrefix: string;\n /** The generated instance client. */\n client: Record;\n /** How that client takes its inputs. Defaults to `'grouped'`, the generated default. */\n argsStyle?: 'grouped' | 'flat';\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\nexport type CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\nexport type CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\nexport type CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\nexport type CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The name to print in help: the command the CLI was invoked as. A global install resolves\n * `argv[1]` to the bin itself, so its basename is exactly what the user typed. A Windows\n * `.cmd` shim, a `node dist/cafe.cli.js`, and a `tsx client.cli.ts` run all pass the script\n * path instead — printing that would name a command nobody can type, so a script extension\n * and the `.cli` marker come off: `cafe.cli.js` prints `cafe`.\n */\nexport function invokedName(scriptPath: string | undefined, fallback: string): string {\n if (scriptPath === undefined) return fallback;\n const base = scriptPath.replace(/^.*[\\\\/]/, '');\n const withoutExtension = base.replace(/\\.(mjs|cjs|js|mts|cts|ts|cmd|bat|ps1|exe)$/i, '');\n const name = withoutExtension.replace(/\\.cli$/i, '');\n return name === '' ? fallback : name;\n}\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/**\n * The parsed argv as one call input, in the style the wired client takes: grouped by layer\n * (the default) or merged into one object.\n */\nfunction callInputs(\n command: CliCommand,\n positionals: Record,\n params: Record,\n body: unknown,\n argsStyle: CliWiring['argsStyle']\n): Record | undefined {\n const inputs: Record = {};\n // A command the generator marked `grouped` keeps the namespaced shape even here.\n if (argsStyle === 'flat' && command.argsStyle !== 'grouped') {\n Object.assign(inputs, positionals, params);\n if (body !== undefined) {\n if (command.body?.merged === true) Object.assign(inputs, body as Record);\n else inputs.body = body;\n }\n } else {\n if (Object.keys(positionals).length > 0) inputs.path = positionals;\n if (Object.keys(params).length > 0) inputs.query = params;\n if (body !== undefined) inputs.body = body;\n }\n return Object.keys(inputs).length > 0 ? inputs : undefined;\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n // An untagged operation is only ever addressed by its bare name, so when that name is also\n // a group slug the name wins — reading it as the group would leave the command unreachable.\n // A tagged operation in the same position keeps yielding to group help: it is still\n // reachable as ` `.\n const untagged = commands.some((c) => c.group === undefined && c.name === argv[0]);\n let command: CliCommand | undefined;\n let rest: string[];\n if (!untagged && slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** `cafe-api` → `CAFE_API`: the casing of every credential variable this CLI reads. */\nexport function constantCase(value: string): string {\n return value\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix;\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${constantCase(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n name: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n name,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${name} ${topic} …`, '', 'Commands:']\n : [`Usage: ${name} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${constantCase(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${name} ${grouped ? ' ' : ''} --help for command details; ${name} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nexport async function runCli(sources: CommandSource[], argv: string[]): Promise;\nexport async function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.name)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], name: string): string[] {\n const lines = [`Usage: ${name} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${name} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(\n commands,\n wiring.name,\n wiring.schemes ?? [],\n wiring.envPrefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const argument = callInputs(command, positionals, params, body, wiring.argsStyle);\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", +} as const; + +/** Inline-embed variants: imports dropped, `export` stripped outside the kept surface. */ +export const RUNTIME_SOURCES_STRIPPED = { + 'types.ts': + "/**\n * The public type surface of the client runtime — `@redocly/client-generator`'s\n * app-facing runtime module. Pure types, no runtime code (excluded from coverage).\n * The generator emits `OPERATIONS` literals typed\n * `satisfies Record` against this module, so an\n * incompatible runtime/generated pair fails the consumer's build (the semver skew guard).\n */\n\n/** How one operation parameter is sent: its location plus OpenAPI query-serialization hints. */\nexport type ParamSpec = {\n name: string;\n in: 'path' | 'query' | 'header' | 'cookie';\n style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject';\n explode?: boolean;\n allowReserved?: boolean;\n};\n\n/** One security scheme, denormalized onto the operation (`scheme` names the spec's scheme). */\nexport type SecuritySpec =\n | { scheme: string; kind: 'bearer' | 'basic' }\n | { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' };\n\n/**\n * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members).\n * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value.\n */\nexport type PaginationSpec =\n | {\n style: 'cursor';\n /** The query param the iterator advances with the response's cursor. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the next cursor in the page. */\n nextCursor: string;\n /** Optional pointer to a boolean \"more pages\" flag — `false` stops iteration. */\n hasMore?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n style: 'offset' | 'page';\n /** The numeric query param the iterator advances. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n /** RFC 8288: follow the response's `Link` header `rel=\"next\"`; stop when absent. */\n style: 'link';\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n };\n\n/** The frozen data contract between generated code and the runtime: one operation's wire shape. */\nexport type OperationDescriptor = {\n id: string;\n method: string;\n path: string;\n tags?: readonly string[];\n params?: readonly ParamSpec[];\n /** `multipart: true` marks a typed object body serialized to FormData by the runtime. */\n body?: { contentType: string; multipart?: boolean };\n /** Defaults to `'json'` (content-type negotiation on parse). */\n responseKind?: 'json' | 'text' | 'blob' | 'void' | 'sse';\n sseDataKind?: 'json' | 'text';\n /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */\n security?: readonly (readonly SecuritySpec[])[];\n pagination?: PaginationSpec;\n /**\n * `'grouped'` marks an operation that takes its inputs namespaced by layer even on a\n * `argsStyle: 'flat'` client — the generator sets it where a merged call could not carry\n * one name for two layers, and the operation's own input type says the same.\n */\n argsStyle?: 'grouped';\n /**\n * Declared success-response headers for throw-mode `{ envelope: true }`.\n * `name` is the lowercased wire name; `key` is the camelCase envelope property.\n */\n responseHeaders?: readonly ResponseHeaderSpec[];\n};\n\n/** One declared response header the runtime coerces into the envelope `headers` object. */\nexport type ResponseHeaderSpec = {\n name: string;\n key: string;\n type: 'string' | 'number' | 'boolean';\n};\n\n/** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */\nexport type QueryValue =\n | string\n | number\n | boolean\n | null\n | undefined\n | Array\n | Record;\n\n/** A credential: a literal, or a (possibly async) function resolved per request (refresh flows). */\nexport type TokenProvider = string | (() => string | Promise);\n\n/** Per-instance credentials, keyed by the scheme kinds the runtime can inject. */\nexport type AuthCredentials = {\n bearer?: TokenProvider;\n basic?: { username: string; password: string };\n apiKey?: Record;\n};\n\n/** Backoff shape: 'fixed' = constant delay; 'exponential' = doubling per attempt. */\nexport type RetryStrategy = 'fixed' | 'exponential';\n\n/**\n * The operation's identity, exposed to middleware for targeting (`ctx.operation`).\n * Generated clients instantiate the type parameters with the spec's literal unions\n * (`OperationId`/`OperationPath`/`OperationTag`) so a misspelled operation id in a\n * middleware comparison fails to compile; the string defaults keep every\n * spec-independent consumer (`runtime-contract.ts`, the runtime internals) working\n * with the base shape. `tags` stays mutable (`Tag[]`) so setup-contract types\n * (byte-locked to generated output) remain assignable through middleware callbacks.\n */\nexport type OperationContext<\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n> = { id: Id; path: Path; tags: Tag[] };\n\n/** The mutable request context threaded through the middleware chain. */\nexport type RequestContext = {\n url: string;\n method: string;\n headers: Record;\n body?: unknown;\n operation: Op;\n};\n\n/** The failed attempt handed to a custom `retryOn`: exactly one of `response`/`error` is set. */\nexport type RetryContext = {\n attempt: number;\n request: RequestContext;\n response?: Response;\n error?: unknown;\n};\n\n/** Opt-in retry policy; a per-call override merges field-by-field over the config policy. */\nexport type RetryConfig = {\n retries?: number;\n retryDelay?: number;\n retryStrategy?: RetryStrategy;\n jitter?: boolean;\n retryOn?: (ctx: RetryContext) => boolean | Promise;\n};\n\n/**\n * Structural stand-in for the runtime's ApiError so this module stays import-free\n * (pure types); the real `ApiError` class is assignable to it.\n */\nexport type ApiErrorLike = globalThis.Error & {\n url: string;\n status: number;\n statusText: string;\n body: unknown;\n};\n\n/** One interceptor: any subset of the three hooks. */\nexport type Middleware = {\n onRequest?: (ctx: RequestContext) => void | Promise;\n onResponse?: (\n response: Response,\n ctx: RequestContext\n ) => Response | void | Promise;\n /** Throw mode only: may map/replace the error. */\n // `globalThis.Error` so a spec schema named `Error` cannot shadow it in inline mode.\n onError?: (\n error: ApiErrorLike,\n ctx: RequestContext\n ) => globalThis.Error | Promise;\n};\n\n/** Client configuration: transport, defaults, retry policy, middleware, and credentials. */\nexport type ClientConfig = {\n serverUrl?: string;\n fetch?: typeof fetch;\n headers?:\n | Record\n | (() => Record | Promise>);\n retry?: RetryConfig;\n /** Milliseconds before a request attempt aborts (covers the body read too; each retry\n * attempt gets a fresh budget). Per-call `timeout` overrides it, `0` disables it.\n * SSE streams are long-lived by design and never inherit this value. */\n timeout?: number;\n /** Send an `Idempotency-Key` header on POST/PATCH (one stable key per logical call,\n * reused across retry attempts) — which also makes those retries safe under the\n * default retry policy. `true` generates a UUID per call; a function supplies the key. */\n idempotencyKey?: boolean | (() => string);\n /** Identifies this client to the API via an `X-Redocly-Client` header (the generator\n * bakes a default). Sent only OUTSIDE browsers — a custom header would force a CORS\n * preflight. Override with your own value, or `false` to disable. */\n clientHeader?: string | false;\n middleware?: Middleware[];\n auth?: AuthCredentials;\n /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */\n errorMode?: 'throw' | 'result';\n /**\n * How each call spells its inputs: `'grouped'` (the default) namespaces them by layer —\n * `{ path, query, headers, cookies, body }` — and `'flat'` takes one merged object.\n * Fixed at generate time, like `errorMode`, because it shapes the static types.\n */\n argsStyle?: 'grouped' | 'flat';\n onRequest?: Middleware['onRequest'];\n onResponse?: Middleware['onResponse'];\n onError?: Middleware['onError'];\n};\n\n/** Response readers for the per-call `parseAs` override. */\nexport type ParseAs = 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'stream';\n\n/** Per-call options: standard `RequestInit` plus a retry override, a timeout override\n * (`0` disables the config default), and a forced reader. */\nexport type RequestOptions = RequestInit & {\n retry?: RetryConfig;\n timeout?: number;\n /** Per-call idempotency key: a literal key, `true` to generate one, `false` to skip. */\n idempotencyKey?: string | boolean | (() => string);\n parseAs?: ParseAs;\n /**\n * Throw mode only: return `{ data, headers, response }` instead of the parsed body;\n * ignored in result mode. The explicit `| undefined` keeps the wrappers' emitted\n * `envelope: undefined` strip legal under `exactOptionalPropertyTypes`.\n */\n envelope?: boolean | undefined;\n};\n\n/** Throw-mode success envelope when `RequestOptions.envelope` is `true`. */\nexport type Envelope> = {\n data: TData;\n headers: THeaders;\n response: Response;\n};\n\n/** Per-call options for an SSE stream; reconnect defaults to true. */\nexport type SseOptions = RequestInit & { reconnect?: boolean; reconnectDelay?: number };\n\n/** A single decoded Server-Sent Event with its payload typed from the spec. */\nexport type ServerSentEvent = { event?: string; data: T; id?: string; retry?: number };\n\n/** Result-mode return shape: exactly one of `data`/`error` is set. */\nexport type Result =\n | { data: TData; error: undefined; response: Response }\n | { data: undefined; error: TError; response: Response };\n\n/**\n * The generated `Ops` type's shape: per-operation args/result, plus `kind: 'sse'` for\n * streams and, for paginated operations, `item` (the page's element type) and — on\n * result-mode clients only — `page` (the RAW page type `.pages()` yields, since\n * iteration unwraps the `Result` envelope the one-shot `result` carries).\n */\nexport type OpsShape = Record<\n string,\n {\n args: object;\n result: unknown;\n kind?: 'sse';\n item?: unknown;\n page?: unknown;\n /** Declared success-response headers for `{ envelope: true }` (camelCase keys). */\n headers?: object;\n /** Result-mode entries ignore the throw-only `envelope` option. */\n mode?: 'result';\n }\n>;\n\n/** The always-present client members (assigned after the operation loop — they win collisions). */\nexport type ClientCore = {\n /** Merge into the config; note `middleware` REPLACES the chain (use `use()` to compose). */\n configure(config: ClientConfig): void;\n /** Append interceptors (composes with baked/publisher middleware). */\n use(...middleware: Middleware[]): void;\n auth: {\n bearer(token: TokenProvider): void;\n basic(username: string, password: string): void;\n apiKey(scheme: string, value: TokenProvider): void;\n };\n};\n\n/**\n * The standard TypeScript optionality probe: `{}` has no required members, so\n * `{} extends A` is true exactly when every member of `A` is optional.\n */\n// oxlint-disable-next-line typescript/no-empty-object-type\ntype NoRequiredKeys = {} extends A ? true : false;\n\n/**\n * The page type `.pages()` yields: the RAW page declared by `page` (the generator\n * writes it only on result-mode paginated entries, whose `result` is the envelope),\n * or the method's own `result` (throw mode — already the raw page).\n */\ntype PageOf = Entry extends { page: unknown }\n ? Entry['page']\n : Entry['result'];\n\n/**\n * The auto-pagination members intersected onto a paginated method — present exactly when\n * the Ops entry declares `item` (the generator writes it only for paginated operations).\n * Args optionality mirrors the method's own; `unknown` otherwise (identity under `&`).\n * Iteration is error-mode-agnostic: `.pages()`/`.items()` yield raw pages/items, and a\n * failed page aborts iteration by throwing `ApiError`, even on result-mode clients; the\n * `onError` middleware hook (throw-mode-only) is not invoked.\n */\ntype Paginated = 'item' extends keyof Entry\n ? NoRequiredKeys extends true\n ? {\n pages(args?: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args?: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : {\n pages(args: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : unknown;\n\n/**\n * The stable identity every client method carries: the SPEC operationId (also set as\n * `fn.name`, but `operationId` is the explicit, minification-proof form) — a robust\n * cache key for consumer wrappers (react-query keys and the like).\n */\nexport type OperationMethodIdentity = { readonly operationId: string };\n\n/** Declared response-header bag for an Ops entry; empty object when none are declared. */\ntype HeadersOf = 'headers' extends keyof Entry\n ? NonNullable\n : Record;\n\n/**\n * Return type of a throw-mode call: the body by default, `Envelope<…>` for a literal\n * `envelope: true`, their union when the flag is a widened `boolean`. Exact\n * `RequestOptions` stays the body — pre-envelope package-mode flat sugar typed every\n * `init` parameter as `RequestOptions`, and widening that would break upgrades without\n * a regenerate. The `keyof` presence gate keeps `{ headers }` / `{ signal }` as the body\n * (`TInit['envelope']` through `TInit & RequestOptions` would otherwise be\n * `boolean | undefined`).\n */\nexport type EnvelopeResult<\n TData,\n THeaders,\n TInit extends RequestOptions | undefined,\n> = TInit extends undefined\n ? TData\n : RequestOptions extends TInit\n ? TInit extends RequestOptions\n ? TData\n : EnvelopeResultForKnownInit\n : EnvelopeResultForKnownInit;\n\ntype EnvelopeResultForKnownInit = 'envelope' extends keyof TInit\n ? [TInit['envelope' & keyof TInit]] extends [true]\n ? Envelope\n : [TInit['envelope' & keyof TInit]] extends [false | undefined]\n ? TData\n : TData | Envelope\n : TData;\n\n/** A one-shot method whose return shape never varies with per-call options. */\ntype BodyMethod =\n NoRequiredKeys extends true\n ? (args?: Entry['args'], init?: RequestOptions) => Promise\n : (args: Entry['args'], init?: RequestOptions) => Promise;\n\n/**\n * One-shot (non-SSE) method: default returns the body; `{ envelope: true }` returns\n * `{ data, headers, response }` with typed declared headers.\n */\ntype ThrowMethod =\n NoRequiredKeys extends true\n ? (\n args?: Entry['args'],\n init?: Init\n ) => Promise, Init>>\n : (\n args: Entry['args'],\n init?: Init\n ) => Promise, Init>>;\n\n/** The typed instance client: one bound method per operation plus the core members. */\nexport type Client = {\n [K in keyof Ops]: Ops[K] extends { kind: 'sse' }\n ? (NoRequiredKeys extends true\n ? (\n args?: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>\n : (\n args: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>) &\n OperationMethodIdentity\n : (Ops[K] extends { mode: 'result' } ? BodyMethod : ThrowMethod) &\n OperationMethodIdentity &\n Paginated;\n} & ClientCore;", + 'errors.ts': + "/** The error thrown (throw mode) for a non-2xx response, carrying the decoded error body. */\nexport class ApiError extends Error {\n public readonly url: string;\n public readonly status: number;\n public readonly statusText: string;\n public readonly body: unknown;\n constructor(url: string, status: number, statusText: string, body: unknown) {\n super(`Request failed with status ${status}`);\n this.name = 'ApiError';\n this.url = url;\n this.status = status;\n this.statusText = statusText;\n this.body = body;\n }\n}\n\n/** The error thrown when a request attempt exceeds the configured `timeout` — carries\n * the context a log line needs (which operation, what budget, which attempt). */\nexport class TimeoutError extends Error {\n public readonly operationId: string;\n public readonly timeout: number;\n public readonly attempt: number;\n constructor(operationId: string, timeout: number, attempt: number) {\n super(`Request \"${operationId}\" timed out after ${timeout} ms (attempt ${attempt})`);\n this.name = 'TimeoutError';\n this.operationId = operationId;\n this.timeout = timeout;\n this.attempt = attempt;\n }\n}\n\n/** The error to throw for an aborted request: the caller's abort reason when it is an Error. */\n// `globalThis.Error` (not bare `Error`) so a spec schema named `Error` cannot shadow it\n// when this module is embedded alongside generated types (inline mode).\nfunction abortError(signal: AbortSignal): globalThis.Error {\n const reason = (signal as { reason?: unknown }).reason;\n if (reason instanceof Error) return reason;\n return new DOMException('The operation was aborted.', 'AbortError');\n}", + 'url.ts': + "/**\n * The RESOLVED OpenAPI serialization spec for one query parameter — callers apply the\n * OpenAPI defaults (`style: 'form'`, `explode: true`) before building one.\n */\ntype QueryStyle = {\n style: NonNullable;\n explode: boolean;\n allowReserved?: boolean;\n};\n\n/**\n * Encode everything except the RFC-3986 reserved set, for `allowReserved: true` params —\n * `filter=a/b` survives instead of `filter=a%2Fb`.\n */\nfunction encodeReserved(value: string): string {\n return encodeURIComponent(value).replace(\n /%(3A|2F|3F|23|5B|5D|40|21|24|26|27|28|29|2A|2B|2C|3B|3D)/g,\n (match) => decodeURIComponent(match)\n );\n}\n\n/** Substitute `{name}` template segments with encoded values; a missing value is a caller bug. */\nfunction substitutePath(template: string, values: Record): string {\n return template.replace(/\\{([^{}]+)\\}/g, (_match, name: string) => {\n const value = values[name];\n if (value === undefined) throw new Error(`Missing path parameter \"${name}\"`);\n return encodeURIComponent(String(value));\n });\n}\n\n/**\n * Build the request URL: `serverUrl` (trailing slash trimmed) + path + serialized query.\n * Query parameters honor their OpenAPI `style`/`explode`/`allowReserved` (from `styles`);\n * without a spec, arrays repeat the key (`form`+`explode`), objects serialize as\n * `deepObject` brackets, and `null`/`undefined` entries are skipped.\n */\nfunction buildUrl(\n serverUrl: string,\n path: string,\n query?: Record,\n styles?: Record\n): string {\n // Trim trailing slashes with a scan, not `/\\/+$/` — an anchored `+` regex is\n // quadratic on adversarial many-slash input (the server URL is caller data).\n let end = serverUrl.length;\n while (end > 0 && serverUrl.charCodeAt(end - 1) === 47 /* '/' */) end--;\n const url = serverUrl.slice(0, end) + path;\n if (!query) return url;\n const params = new URLSearchParams();\n const raw: string[] = [];\n for (const [key, value] of Object.entries(query)) {\n if (value === undefined || value === null) continue;\n const spec = styles?.[key];\n if (!spec) {\n if (Array.isArray(value)) {\n for (const v of value) {\n if (v !== undefined && v !== null) params.append(key, String(v));\n }\n } else if (Object(value) === value) {\n // Object-valued query params use `deepObject` style: key[subKey]=subValue.\n for (const [subKey, subValue] of Object.entries(value)) {\n if (subValue !== undefined && subValue !== null) {\n params.append(`${key}[${subKey}]`, String(subValue));\n }\n }\n } else {\n params.append(key, String(value));\n }\n continue;\n }\n if (Array.isArray(value)) {\n const items = value.filter((v) => v !== undefined && v !== null).map(String);\n if (spec.style === 'form' && spec.explode) {\n for (const v of items) {\n if (spec.allowReserved) raw.push(`${key}=${encodeReserved(v)}`);\n else params.append(key, v);\n }\n } else {\n // Delimited styles put the LITERAL delimiter on the wire; only the\n // values are encoded. `%20` (not `+`) is the literal space delimiter.\n const delim =\n spec.style === 'pipeDelimited' ? '|' : spec.style === 'spaceDelimited' ? '%20' : ',';\n const enc = spec.allowReserved ? encodeReserved : encodeURIComponent;\n raw.push(`${encodeURIComponent(key)}=${items.map(enc).join(delim)}`);\n }\n } else if (Object(value) === value) {\n // `deepObject` (and any object spec, for now): key[subKey]=subValue.\n for (const [subKey, subValue] of Object.entries(value)) {\n if (subValue !== undefined && subValue !== null) {\n if (spec.allowReserved) raw.push(`${key}[${subKey}]=${encodeReserved(String(subValue))}`);\n else params.append(`${key}[${subKey}]`, String(subValue));\n }\n }\n } else if (spec.allowReserved) {\n raw.push(`${key}=${encodeReserved(String(value))}`);\n } else {\n params.append(key, String(value));\n }\n }\n const qs = [params.toString(), ...raw].filter(Boolean).join('&');\n return qs ? `${url}?${qs}` : url;\n}", + 'parse.ts': + "/**\n * Read the response body per `kind`. `'auto'` negotiates from the content type\n * (JSON, then `text/*`, then Blob); `204` responses read nothing. A `'void'`\n * operation (no declared 2xx content) still returns a JSON body the server\n * actually sends: the static type stays `void`, but silently dropping real data\n * behind a spec gap is the worse failure — consumers can reach it with a cast\n * while the API description catches up.\n */\nasync function parse(response: Response, kind: ParseAs | 'void'): Promise {\n if (kind === 'void') {\n if (response.status === 204 || response.status === 205 || response.status === 304) {\n return undefined;\n }\n const contentType = (response.headers.get('content-type') ?? '').toLowerCase();\n if (!contentType.includes('json')) return undefined;\n // Best-effort: an empty or malformed body on an undeclared response stays undefined.\n const text = await response.text().catch(() => '');\n if (text === '') return undefined;\n try {\n return JSON.parse(text);\n } catch {\n return undefined;\n }\n }\n if (response.status === 204) return undefined;\n if (kind === 'stream') return response.body;\n if (kind === 'blob') return response.blob();\n if (kind === 'arrayBuffer') return response.arrayBuffer();\n if (kind === 'formData') return response.formData();\n if (kind === 'text') return response.text();\n if (kind === 'json') return response.json();\n // 'auto' — negotiate from the response's content type (case-insensitively:\n // `Text/Plain` and `application/JSON` are valid per RFC 9110).\n const contentType = (response.headers.get('content-type') ?? '').toLowerCase();\n if (contentType.includes('json')) return response.json();\n if (contentType.startsWith('text/')) return response.text();\n // An untyped body reads as a Blob — but an EMPTY one resolves to undefined: a 2xx\n // with `Content-Length: 0` must not yield a truthy `new Blob([])` that silently\n // defeats every `!data` guard downstream.\n const blob = await response.blob();\n return blob.size > 0 ? blob : undefined;\n}\n\n/** Best-effort decode of a non-2xx body (JSON when declared, else text; undefined on failure). */\nasync function readError(response: Response): Promise {\n const contentType = response.headers.get('content-type') ?? '';\n if (contentType.toLowerCase().includes('json')) {\n return response.json().catch(() => undefined);\n }\n return response.text().catch(() => undefined);\n}", + 'retry.ts': + "const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS']);\nconst TRANSIENT_STATUS = new Set([408, 429, 500, 502, 503, 504]);\n\n/**\n * The default retry predicate: idempotent methods — or any request carrying an\n * `Idempotency-Key` header, which makes re-sending safe — on a transport error or a\n * transient status. A custom `retryOn` fully replaces this (no method check kept).\n */\nexport function defaultRetryOn(ctx: RetryContext): boolean {\n const safeToResend =\n IDEMPOTENT_METHODS.has(ctx.request.method.toUpperCase()) ||\n 'Idempotency-Key' in ctx.request.headers ||\n 'idempotency-key' in ctx.request.headers;\n if (!safeToResend) return false;\n return ctx.response === undefined || TRANSIENT_STATUS.has(ctx.response.status);\n}\n\n/**\n * The delay before the next attempt: a `Retry-After` header (seconds or HTTP-date)\n * wins; otherwise fixed/exponential backoff over `retryDelay`, with full jitter\n * unless `jitter === false`.\n */\nfunction retryDelay(retry: RetryConfig, attempt: number, retryAfter: string | null): number {\n if (retryAfter) {\n const seconds = Number(retryAfter);\n if (!Number.isNaN(seconds)) return seconds * 1000;\n const when = Date.parse(retryAfter);\n if (!Number.isNaN(when)) return Math.max(0, when - Date.now());\n }\n const base = retry.retryDelay ?? 1000;\n const raw = retry.retryStrategy === 'fixed' ? base : base * Math.pow(2, attempt - 1);\n return retry.jitter === false ? raw : Math.random() * raw;\n}\n\n/** Abort-aware sleep: resolves after `ms`, rejects with the abort reason immediately on abort. */\nfunction sleep(ms: number, signal?: AbortSignal): Promise {\n return new Promise((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError(signal));\n return;\n }\n const onAbort = () => {\n clearTimeout(timer);\n reject(abortError(signal as AbortSignal));\n };\n const timer = setTimeout(() => {\n if (signal) signal.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n if (signal) signal.addEventListener('abort', onAbort, { once: true });\n });\n}", + 'multipart.ts': + "/**\n * Serialize a plain object into `FormData` for a typed `multipart/form-data` body\n * (capability module — wired into `createClient`, never imported by the send core).\n * `Blob`/`File` and strings pass through; `Date`s become ISO strings; arrays append\n * one field per item; other objects are JSON-encoded; everything else is stringified.\n * `undefined`/`null` entries are skipped.\n */\nfunction toFormData(body: Record): FormData {\n const fd = new FormData();\n const append = (key: string, value: unknown): void => {\n if (value === undefined || value === null) return;\n if (value instanceof Blob || typeof value === 'string') fd.append(key, value);\n else if (value instanceof Date) fd.append(key, value.toISOString());\n else if (Object(value) === value) fd.append(key, JSON.stringify(value));\n else fd.append(key, String(value));\n };\n for (const [key, value] of Object.entries(body)) {\n if (Array.isArray(value)) for (const item of value) append(key, item);\n else append(key, value);\n }\n return fd;\n}", + 'auth.ts': + "/** Resolve a credential: a literal passes through; a function is awaited per request. */\nasync function resolveToken(provider: TokenProvider): Promise {\n return typeof provider === 'function' ? await provider() : provider;\n}\n\n/** UTF-8-safe base64: bare `btoa` throws on non-Latin-1 credentials (RFC 7617 allows UTF-8). */\nfunction encodeBase64(text: string): string {\n let binary = '';\n for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\n/** Whether a credential for this scheme is configured on the instance. */\nfunction isConfigured(scheme: SecuritySpec, config: ClientConfig): boolean {\n if (scheme.kind === 'apiKey') return config.auth?.apiKey?.[scheme.scheme] !== undefined;\n if (scheme.kind === 'bearer') return config.auth?.bearer !== undefined;\n return config.auth?.basic !== undefined;\n}\n\n/**\n * Build the auth headers/query for one operation's `security` OR-alternatives from the\n * instance credentials (`config.auth`) — capability module, wired into `createClient`.\n * The first alternative whose schemes (an AND-set) are all configured is applied, so\n * \"bearer OR apiKey\" works with either credential and never sends both. When none is\n * fully configured, the first alternative's configured schemes are still sent (the\n * server rejects the request, mirroring the previous behavior).\n * Cookie-borne apiKeys fold into a single `Cookie` header joined with `; `.\n */\nasync function resolveAuth(\n security: readonly (readonly SecuritySpec[])[],\n config: ClientConfig\n): Promise<{ headers: Record; query: Record }> {\n const alternative =\n security.find((schemes) => schemes.every((scheme) => isConfigured(scheme, config))) ??\n security[0] ??\n [];\n const headers: Record = {};\n const query: Record = {};\n const cookies: string[] = [];\n for (const scheme of alternative) {\n if (scheme.kind === 'apiKey') {\n const provider = config.auth?.apiKey?.[scheme.scheme];\n if (provider === undefined) continue;\n const value = await resolveToken(provider);\n if (scheme.in === 'header') headers[scheme.name] = value;\n else if (scheme.in === 'query') query[scheme.name] = value;\n // Cookie values may contain reserved characters (`;`, `=`, space, …); percent-encode\n // so the credential can't break the `Cookie` header syntax.\n else cookies.push(`${scheme.name}=${encodeURIComponent(value)}`);\n } else if (scheme.kind === 'bearer') {\n const provider = config.auth?.bearer;\n if (provider !== undefined) headers.Authorization = `Bearer ${await resolveToken(provider)}`;\n } else {\n const basic = config.auth?.basic;\n if (basic !== undefined) {\n headers.Authorization = `Basic ${encodeBase64(`${basic.username}:${basic.password}`)}`;\n }\n }\n }\n if (cookies.length > 0) headers.Cookie = cookies.join('; ');\n return { headers, query };\n}", + 'setup.ts': + "/**\n * Merge a publisher's baked setup (`defineClientSetup({...})`) with the app's config:\n * app config fields win per-field over baked defaults, while middleware composes —\n * baked middleware runs first, then the app's.\n */\nexport function mergeSetup(\n setup: { config?: ClientConfig; middleware?: Middleware[] } | undefined,\n config: ClientConfig = {}\n): ClientConfig {\n return {\n ...setup?.config,\n ...config,\n middleware: [...(setup?.middleware ?? []), ...(config.middleware ?? [])],\n };\n}", + 'send.ts': + "/**\n * Optional behaviors the send core can use but never statically imports — wired by\n * `createClient` (the same seam the future inline-mode assembler relies on).\n */\ntype SendCapabilities = {\n /** Serialize a typed multipart body (a plain object) to FormData. */\n serializeMultipart?: (body: Record) => FormData;\n};\n\n/**\n * Normalize a caller's `HeadersInit` (plain record, `Headers` instance, or entry pairs)\n * to a plain record — spreading a `Headers` or an array contributes no entries.\n */\nfunction toHeaderRecord(headers: HeadersInit | undefined): Record {\n if (headers === undefined) return {};\n if (headers instanceof Headers) {\n const record: Record = {};\n headers.forEach((value, key) => {\n record[key] = value;\n });\n return record;\n }\n if (Array.isArray(headers)) return Object.fromEntries(headers);\n return headers;\n}\n\n/**\n * The effective middleware chain for a request: the single `onRequest`/`onResponse`/\n * `onError` config hooks as one implicit first middleware, then `config.middleware`.\n */\nfunction middlewareChain(config: ClientConfig): Middleware[] {\n const single =\n config.onRequest || config.onResponse || config.onError\n ? [{ onRequest: config.onRequest, onResponse: config.onResponse, onError: config.onError }]\n : [];\n return [...single, ...(config.middleware ?? [])];\n}\n\n/**\n * The fetch core shared by every operation: default + config + per-call headers, the\n * `onRequest` chain (BEFORE body serialization, so mutations are sent), body\n * serialization (JSON, or FormData via the multipart capability), the retry loop\n * (idempotent-only defaults, `Retry-After`, abandoned-body drain), and the reverse\n * `onResponse` onion. Returns the final response plus the request context.\n */\nasync function send(\n config: ClientConfig,\n op: OperationContext,\n url: string,\n init: RequestOptions,\n body: unknown | undefined,\n bodySpec: { contentType: string; multipart?: boolean } | undefined,\n caps: SendCapabilities,\n accept = 'application/json'\n): Promise<{ response: Response; context: RequestContext }> {\n const { retry: callRetry, timeout: callTimeout, idempotencyKey: callKey, ...fetchInit } = init;\n const retry: RetryConfig = { ...config.retry, ...callRetry };\n const timeout = callTimeout ?? config.timeout;\n const idempotency = callKey ?? config.idempotencyKey;\n const extra = typeof config.headers === 'function' ? await config.headers() : config.headers;\n const headers: Record = {\n Accept: accept,\n ...extra,\n ...toHeaderRecord(fetchInit.headers),\n };\n const method = (fetchInit.method ?? 'GET').toUpperCase();\n // One stable key per LOGICAL call — set before the retry loop so every attempt\n // re-sends the same key; a caller-provided header always wins.\n if (\n idempotency !== undefined &&\n idempotency !== false &&\n (method === 'POST' || method === 'PATCH') &&\n !('Idempotency-Key' in headers) &&\n !('idempotency-key' in headers)\n ) {\n headers['Idempotency-Key'] =\n typeof idempotency === 'string'\n ? idempotency\n : typeof idempotency === 'function'\n ? idempotency()\n : crypto.randomUUID();\n }\n // Client identification for the API owner's telemetry — never in browsers, where a\n // custom header would force a CORS preflight the API may not allow.\n if (\n typeof config.clientHeader === 'string' &&\n typeof document === 'undefined' &&\n !('X-Redocly-Client' in headers) &&\n !('x-redocly-client' in headers)\n ) {\n headers['X-Redocly-Client'] = config.clientHeader;\n }\n const context: RequestContext = {\n url,\n method: fetchInit.method ?? 'GET',\n headers,\n body,\n operation: op,\n };\n const middleware = middlewareChain(config);\n for (const mw of middleware) if (mw.onRequest) await mw.onRequest(context);\n // Serialize AFTER onRequest so body mutations (case conversion, enveloping, signing) take effect.\n let payload: BodyInit | undefined;\n if (context.body !== undefined) {\n const value = context.body;\n const isBinary =\n value instanceof Blob ||\n value instanceof ArrayBuffer ||\n ArrayBuffer.isView(value as ArrayBufferView);\n const isFormData = typeof FormData !== 'undefined' && value instanceof FormData;\n const isURLSearchParams = value instanceof URLSearchParams;\n if (isFormData || isURLSearchParams || isBinary || typeof value === 'string') {\n payload = value as BodyInit;\n } else if (bodySpec?.multipart === true) {\n if (!caps.serializeMultipart) {\n throw new Error('Multipart capability not wired: cannot serialize the request body');\n }\n payload = caps.serializeMultipart(value as Record);\n } else {\n payload = JSON.stringify(value);\n if (!('Content-Type' in context.headers) && !('content-type' in context.headers)) {\n // The spec's declared request content type (e.g. application/merge-patch+json).\n context.headers['Content-Type'] = bodySpec?.contentType ?? 'application/json';\n }\n }\n }\n const doFetch = config.fetch ?? fetch;\n const maxAttempts = 1 + (retry.retries ?? 0);\n const retryOn = retry.retryOn ?? defaultRetryOn;\n const signal = fetchInit.signal ?? undefined;\n\n let attempt = 0;\n while (true) {\n attempt++;\n if (signal?.aborted) throw abortError(signal);\n // A fresh timeout budget per attempt; the caller's signal still wins the race.\n // The composed signal also governs reading the response body.\n const attemptSignal = timeout\n ? signal\n ? AbortSignal.any([signal, AbortSignal.timeout(timeout)])\n : AbortSignal.timeout(timeout)\n : signal;\n let response: Response;\n try {\n response = await doFetch(context.url, {\n ...fetchInit,\n signal: attemptSignal,\n method: context.method,\n headers: context.headers,\n body: payload,\n });\n } catch (error) {\n if (\n attempt < maxAttempts &&\n !signal?.aborted &&\n (await retryOn({ attempt, request: context, error }))\n ) {\n await sleep(retryDelay(retry, attempt, null), signal);\n continue;\n }\n // Our timeout fired (never the caller's own abort — that rethrows untouched):\n // wrap the bare DOMException with the context a log line needs.\n if (\n timeout &&\n !signal?.aborted &&\n error instanceof DOMException &&\n error.name === 'TimeoutError'\n ) {\n throw new TimeoutError(op.id, timeout, attempt);\n }\n throw error;\n }\n // Reverse order: the last-registered middleware wraps closest to the network (onion).\n for (let i = middleware.length - 1; i >= 0; i--) {\n const onResponse = middleware[i].onResponse;\n if (onResponse) {\n const replaced = await onResponse(response, context);\n if (replaced && replaced !== response) {\n // Cancel the abandoned original's body — like the retry path, an unread body\n // keeps its connection checked out under Node/undici.\n await response.body?.cancel().catch(() => undefined);\n response = replaced;\n }\n }\n }\n if (\n !response.ok &&\n attempt < maxAttempts &&\n !signal?.aborted &&\n (await retryOn({ attempt, request: context, response }))\n ) {\n const retryAfter = response.headers.get('retry-after');\n // Drain the abandoned response body before the next attempt: an unread body\n // keeps the connection checked out (and can stall the pool) under Node/undici\n // and other strict HTTP clients. Ignore errors (e.g. a middleware already read it).\n await response.body?.cancel().catch(() => undefined);\n await sleep(retryDelay(retry, attempt, retryAfter), signal);\n continue;\n }\n return { response, context };\n }\n}", + 'sse.ts': + "/**\n * A frame delimiter: two consecutive line terminators (each CR, LF, or CRLF, per the SSE\n * spec — so mixed endings like `\\n\\r\\n` are valid boundaries, not just matching pairs).\n */\nconst FRAME_DELIMITER = /(?:\\r\\n|\\r|\\n){2}/;\n\n/**\n * A terminally malformed event stream — unparseable JSON `data` or an unbounded frame.\n * A stable bad payload, not a dropped connection, so the stream never reconnects on it.\n */\nclass SseParseError extends Error {}\n\n/**\n * Consume a `text/event-stream` operation as typed events (capability module — wired\n * into `createClient`). Auto-reconnects on dropped connections, resuming from the last\n * seen event id via `Last-Event-ID` (backoff: the server's `retry:` value, then\n * `reconnectDelay`, then 1s — exponential with jitter, capped at 30s). A clean stream\n * end flushes a trailing frame and finishes; `break`/abort end the iterator cleanly.\n */\nasync function* sse(\n config: ClientConfig,\n op: OperationContext,\n prepare: () => Promise<{ url: string; init: SseOptions; body?: unknown }>,\n dataKind: 'json' | 'text' = 'text'\n): AsyncGenerator> {\n let lastEventId: string | undefined;\n let serverRetry: number | undefined;\n let failures = 0;\n while (true) {\n // Re-prepare each attempt so a refresh-style TokenProvider yields a fresh credential\n // on reconnect (the auth is baked into `url` query + `init.headers`). `reconnect`,\n // `reconnectDelay`, and `signal` come from the caller's original options unchanged.\n const { url, init, body: requestBody } = await prepare();\n const { reconnect = true, reconnectDelay, ...rest } = init;\n const signal = rest.signal ?? undefined;\n if (signal?.aborted) return;\n const headers: Record = {\n Accept: 'text/event-stream',\n ...toHeaderRecord(rest.headers),\n };\n const sendHeaders =\n lastEventId === undefined ? headers : { ...headers, 'Last-Event-ID': lastEventId };\n try {\n const { response } = await send(\n config,\n op,\n url,\n // `timeout: 0` opts the stream out of a config-level timeout — an event stream\n // is long-lived by design and must not be severed after N milliseconds.\n { ...rest, method: rest.method ?? 'GET', headers: sendHeaders, timeout: 0 },\n requestBody,\n undefined,\n {}\n );\n if (!response.ok) {\n const errorBody = await readError(response);\n throw new ApiError(url, response.status, response.statusText, errorBody);\n }\n failures = 0;\n const body = response.body;\n if (!body) return;\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n try {\n while (true) {\n const { done, value } = await reader.read();\n buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });\n let match: RegExpExecArray | null;\n while ((match = FRAME_DELIMITER.exec(buffer)) !== null) {\n const raw = buffer.slice(0, match.index);\n buffer = buffer.slice(match.index + match[0].length);\n const event = parseSseFrame(raw, dataKind);\n if (event) {\n if (event.id !== undefined) lastEventId = event.id;\n if (event.retry !== undefined) serverRetry = event.retry;\n yield event as ServerSentEvent;\n }\n }\n if (done) {\n // Stream closed cleanly. Flush a final event that arrived without a trailing\n // delimiter, then finish — a clean end is not a dropped connection, so do not reconnect.\n const event = buffer.length > 0 ? parseSseFrame(buffer, dataKind) : undefined;\n if (event) {\n if (event.id !== undefined) lastEventId = event.id;\n if (event.retry !== undefined) serverRetry = event.retry;\n yield event as ServerSentEvent;\n }\n return;\n }\n // Bound memory: a server that never sends a frame delimiter would otherwise\n // grow `buffer` without limit. 1 MiB is far above any real SSE frame.\n if (buffer.length > 1048576) {\n throw new SseParseError('SSE frame exceeded 1048576 characters without a delimiter');\n }\n }\n } finally {\n await reader.cancel().catch(() => undefined);\n }\n } catch (error) {\n if (signal?.aborted) return;\n // A non-OK HTTP response (4xx/5xx) or an unparseable JSON payload is a definitive\n // error, not a transient drop — surface it instead of reconnecting in a loop (a\n // stable bad payload would otherwise reconnect forever).\n if (error instanceof ApiError || error instanceof SseParseError) throw error;\n // A transport failure (connect/DNS/reset) when opening the request, or a mid-stream\n // read error, is a dropped connection: fall through to backoff/reconnect when enabled.\n if (!reconnect) throw error;\n }\n // Only the swallowed-drop path reaches here: reconnect is on and the signal not aborted.\n failures++;\n const base = serverRetry ?? reconnectDelay ?? 1000;\n const delay = Math.min(base * Math.pow(2, failures - 1), 30_000);\n try {\n await sleep(Math.random() * delay, signal);\n } catch {\n return; // sleep rejects only on abort — end the iterator cleanly\n }\n }\n}\n\n/** Parse one raw SSE frame (its lines) into an event; returns undefined for comment-only frames. */\nfunction parseSseFrame(\n raw: string,\n dataKind: 'json' | 'text'\n): ServerSentEvent | undefined {\n let event: string | undefined;\n const dataLines: string[] = [];\n let id: string | undefined;\n let retry: number | undefined;\n let sawField = false;\n for (const line of raw.split(/\\r\\n|\\n|\\r/)) {\n if (line === '' || line.startsWith(':')) continue;\n const colon = line.indexOf(':');\n const field = colon === -1 ? line : line.slice(0, colon);\n let val = colon === -1 ? '' : line.slice(colon + 1);\n if (val.startsWith(' ')) val = val.slice(1);\n sawField = true;\n if (field === 'event') event = val;\n else if (field === 'data') dataLines.push(val);\n else if (field === 'id') id = val;\n else if (field === 'retry') {\n // ASCII digits only, per the EventSource spec — anything else is ignored\n // (`Number('')` is 0 and would zero the reconnect backoff).\n if (/^\\d+$/.test(val)) retry = Number(val);\n }\n }\n if (!sawField) return undefined;\n const dataText = dataLines.join('\\n');\n let data: unknown = dataText;\n if (dataKind === 'json' && dataText !== '') {\n try {\n data = JSON.parse(dataText);\n } catch (error) {\n throw new SseParseError(\n `Failed to parse SSE event data as JSON: ${error instanceof Error ? error.message : String(error)}`\n );\n }\n }\n return { event, data, id, retry };\n}", + 'create-client.ts': + "/**\n * The optional behaviors `createClientCore` can dispatch to but never statically\n * imports. The package's public `createClient` wires the full set; the future\n * inline-mode assembler wires only the capabilities a spec needs.\n */\ntype Capabilities = SendCapabilities & {\n resolveAuth?: (\n security: readonly (readonly SecuritySpec[])[],\n config: ClientConfig\n ) => Promise<{ headers: Record; query: Record }>;\n sse?: (\n config: ClientConfig,\n op: OperationContext,\n // Re-preparing per (re)connect (not a frozen url/init) lets a refresh-style\n // TokenProvider issue a fresh credential after a dropped stream reconnects.\n prepare: () => Promise<{ url: string; init: SseOptions }>,\n dataKind: 'json' | 'text'\n ) => AsyncGenerator>;\n paginate?: {\n pages: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n items: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n // The `link`-style iterators need the raw `Link` header + page URL, which the\n // parsed-page call above cannot carry (the shape mirrors paginate's `LinkPageCall`).\n pagesByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n itemsByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n };\n};\n\n/**\n * One call's inputs, namespaced by transport layer. `argsStyle: 'flat'` clients accept the\n * merged form instead (every parameter and body property at one level) — `namespaceArgs`\n * converts it to this shape before anything downstream reads it.\n */\ntype OperationArgs = {\n path?: Record;\n query?: Record;\n body?: unknown;\n headers?: Record;\n cookies?: Record;\n} & Record;\n\n/** The five layer keys, and the only top-level keys a namespaced call may carry. */\nconst LAYERS: readonly string[] = ['path', 'query', 'body', 'headers', 'cookies'];\n\n/** Where a declared parameter's `in` value puts it. */\nconst LAYER_OF: Record = {\n path: 'path',\n query: 'query',\n header: 'headers',\n cookie: 'cookies',\n};\n\n/**\n * Merged (`argsStyle: 'flat'`) args → the namespaced shape. A key that names a declared\n * parameter goes to that parameter's layer; anything else is a property of the request\n * body, which is how a flat call spells an object body. `body` stays reserved for the\n * operations a flat call cannot merge (an array, a scalar, or a binary body).\n */\nfunction namespaceArgs(op: OperationDescriptor, args: OperationArgs): OperationArgs {\n const layers: Record> = {};\n let body: unknown;\n let properties: Record | undefined;\n const layerOfParam = new Map((op.params ?? []).map((param) => [param.name, param.in]));\n for (const [key, value] of Object.entries(args)) {\n const layer = LAYER_OF[layerOfParam.get(key) ?? ''];\n if (layer !== undefined) {\n (layers[layer] ??= {})[key] = value;\n } else if (key === 'body' && op.body !== undefined) {\n body = value;\n } else if (op.body !== undefined) {\n (properties ??= {})[key] = value;\n } else {\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\": it names no declared parameter, and the operation takes no request body.`\n );\n }\n }\n const namespaced: OperationArgs = {};\n if (layers.path) namespaced.path = layers.path;\n // The flat surface types every query value, so the collected bag is one by construction.\n if (layers.query) namespaced.query = layers.query as Record;\n if (layers.headers) namespaced.headers = layers.headers;\n if (layers.cookies) namespaced.cookies = layers.cookies;\n if (properties !== undefined) namespaced.body = properties;\n else if (body !== undefined) namespaced.body = body;\n return namespaced;\n}\n\n/** The response reader implied by the descriptor (before any per-call `parseAs` override). */\n/**\n * The `Accept` header matching how the response will be read — a blob/text operation\n * must not ask for `application/json` (a content-negotiating server would 406 or\n * answer with a JSON error body instead of the payload). Caller `init.headers` and\n * `config.headers` still override.\n */\nfunction acceptFor(kind: ParseAs | 'void'): string {\n if (kind === 'text') return 'text/*';\n if (kind === 'blob' || kind === 'arrayBuffer' || kind === 'stream' || kind === 'formData') {\n return '*/*';\n }\n return 'application/json'; // json | auto | void\n}\n\nfunction kindFor(op: OperationDescriptor): ParseAs | 'void' {\n if (op.responseKind === 'void' || op.responseKind === 'blob' || op.responseKind === 'text') {\n return op.responseKind;\n }\n return 'auto';\n}\n\n/**\n * The call's inputs in namespaced form, converting first on a flat-style client. An\n * operation the generator marked `argsStyle: 'grouped'` is already namespaced — its names\n * could not be merged, so its input type never offered the flat shape.\n */\nfunction inputOf(\n op: OperationDescriptor,\n args: OperationArgs,\n config: ClientConfig\n): OperationArgs {\n const merged = config.argsStyle === 'flat' && op.argsStyle !== 'grouped';\n return merged ? namespaceArgs(op, args) : args;\n}\n\n/** Route the namespaced args to the request pieces. */\nfunction splitArgs(op: OperationDescriptor, args: OperationArgs) {\n // An unknown layer key can only be a bug (usually flat-style args on a namespaced\n // client). TypeScript catches it, but a transpiler that skips type-checking would\n // otherwise ship a request that silently drops the value — fail the call loudly.\n for (const key of Object.keys(args)) {\n if (!LAYERS.includes(key)) {\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\". Inputs are grouped by layer: ${LAYERS.join(', ')}.`\n );\n }\n }\n return {\n path: args.path ?? {},\n query: args.query,\n body: args.body,\n headers: args.headers,\n cookies: args.cookies,\n };\n}\n\n/**\n * The query-serialization hints for the descriptor's query params. A spec is built only\n * when the param deviates from the OpenAPI defaults (`form` + `explode: true`, encoded),\n * and always fully resolved — so `explode: false` or `allowReserved` alone (no `style`)\n * are honored, and an omitted `explode` keeps the exploded default.\n */\nfunction queryStyles(op: OperationDescriptor): Record | undefined {\n let styles: Record | undefined;\n for (const param of op.params ?? []) {\n if (param.in !== 'query') continue;\n const deviates =\n (param.style !== undefined && param.style !== 'form') ||\n param.explode === false ||\n param.allowReserved === true;\n if (!deviates) continue;\n styles ??= {};\n styles[param.name] = {\n style: param.style ?? 'form',\n explode: param.explode ?? true,\n allowReserved: param.allowReserved,\n };\n }\n return styles;\n}\n\n/** Stringify caller-supplied extra headers, skipping empty entries. */\nfunction stringHeaders(headers: Record | undefined): Record {\n const out: Record = {};\n for (const [key, value] of Object.entries(headers ?? {})) {\n if (value !== undefined && value !== null) out[key] = String(value);\n }\n return out;\n}\n\n/** Build the request pieces an attempt needs: the final URL and the merged per-call init. */\nasync function prepareRequest(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions | SseOptions,\n caps: Capabilities\n): Promise<{ url: string; init: RequestOptions; body: unknown }> {\n const { path, query, body, headers, cookies } = splitArgs(op, args);\n const authed: { headers: Record; query: Record } =\n op.security?.length && caps.resolveAuth\n ? await caps.resolveAuth(op.security, config)\n : { headers: {}, query: {} };\n // Cookie params join the auth-injected cookies in one `Cookie` header (values\n // percent-encoded, like auth cookies). Server-side only — browsers own the header.\n const cookiePairs = Object.entries(cookies ?? {})\n .filter(([, value]) => value !== undefined && value !== null)\n .map(([cookieName, value]) => `${cookieName}=${encodeURIComponent(String(value))}`);\n if (cookiePairs.length > 0) {\n authed.headers.Cookie = [authed.headers.Cookie, ...cookiePairs].filter(Boolean).join('; ');\n }\n const fullQuery: Record = { ...query, ...authed.query };\n const url = buildUrl(\n config.serverUrl ?? '',\n substitutePath(op.path, path),\n Object.keys(fullQuery).length > 0 ? fullQuery : undefined,\n queryStyles(op)\n );\n const mergedInit: RequestOptions = {\n ...init,\n method: op.method.toUpperCase(),\n // Precedence, lowest → highest (later spreads win): injected auth → explicit\n // header params → caller `init.headers` — the caller always overrides both.\n headers: {\n ...authed.headers,\n ...stringHeaders(headers),\n ...toHeaderRecord(init.headers),\n },\n };\n return { url, init: mergedInit, body };\n}\n\n/** Coerce a single declared response header value; omit when absent or unparsable. */\nfunction coerceResponseHeader(\n raw: string | null,\n type: ResponseHeaderSpec['type']\n): string | number | boolean | undefined {\n if (raw === null) return undefined;\n if (type === 'number') {\n if (raw.trim() === '') return undefined;\n const value = Number(raw);\n return Number.isFinite(value) ? value : undefined;\n }\n if (type === 'boolean') {\n const value = raw.trim().toLowerCase();\n if (value === 'true') return true;\n if (value === 'false') return false;\n return undefined;\n }\n return raw;\n}\n\n/** Build the camelCase declared-header bag for a throw-mode envelope. */\nfunction readEnvelopeHeaders(\n response: Response,\n specs: readonly ResponseHeaderSpec[] | undefined\n): Record {\n const headers: Record = {};\n for (const spec of specs ?? []) {\n const value = coerceResponseHeader(response.headers.get(spec.name), spec.type);\n if (value !== undefined) headers[spec.key] = value;\n }\n return headers;\n}\n\n/** One non-SSE call: send, then branch on the configured error mode. */\nasync function execute(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions,\n caps: Capabilities\n): Promise {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // `parseAs` / `envelope` are client options, not fetch RequestInit fields.\n const { parseAs, envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const { response, context } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (config.errorMode === 'result') {\n if (!response.ok) {\n return { data: undefined, error: await readError(response), response };\n }\n return { data: await parse(response, readKind), error: undefined, response };\n }\n if (!response.ok) {\n let error: globalThis.Error = new ApiError(\n context.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n // Thread the error through each middleware's onError in turn (each may replace it).\n for (const mw of middlewareChain(config)) {\n if (mw.onError) error = await mw.onError(error as ApiErrorLike, context);\n }\n throw error;\n }\n const data = await parse(response, readKind);\n if (envelope === true) {\n return {\n data,\n headers: readEnvelopeHeaders(response, op.responseHeaders),\n response,\n };\n }\n return data;\n}\n\n/** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */\nfunction paginateCapability(caps: Capabilities, op: OperationDescriptor) {\n if (!caps.paginate) {\n throw new Error(`Pagination capability not wired: cannot iterate operation \"${op.id}\"`);\n }\n return caps.paginate;\n}\n\n/**\n * The per-page call the iterators drive: the method itself in throw mode; in result\n * mode a wrapper that unwraps the `{ data, error, response }` envelope — the page\n * pointers are data-rooted — rethrowing a failed page as `ApiError` (iteration is\n * error-mode-agnostic; the throw-mode-only `onError` middleware hook is not invoked).\n */\nfunction pageCall(\n method: (args?: OperationArgs, init?: RequestOptions) => Promise,\n config: ClientConfig\n) {\n const callWithoutEnvelope = (args?: OperationArgs, init?: RequestOptions) => {\n if (!init || init.envelope === undefined) return method(args, init);\n const { envelope: _envelope, ...pageInit } = init;\n return method(args, pageInit);\n };\n if (config.errorMode !== 'result') return callWithoutEnvelope;\n return async (args?: OperationArgs, init?: RequestOptions) => {\n const envelope = (await callWithoutEnvelope(args, init)) as {\n data: unknown;\n error: unknown;\n response: Response;\n };\n // Failure is `!response.ok` — NOT `data === undefined`: a successful bodyless page\n // (204/void) also parses to undefined data, and a failed page's `error` can be\n // undefined too (unreadable body). The pointers then miss on the undefined data\n // and iteration stops cleanly, which is the correct semantics for an empty page.\n if (!envelope.response.ok) {\n const { response } = envelope;\n throw new ApiError(response.url, response.status, response.statusText, envelope.error);\n }\n return envelope.data;\n };\n}\n\n/**\n * The per-page call the `link`-style iterators drive: like `execute`, but returning the\n * parsed page together with the raw `Link` header and the page's own URL (for resolving\n * a relative `rel=\"next\"` target). Error-mode-agnostic like all iteration: a failed\n * page throws `ApiError` even on result-mode clients.\n */\nfunction linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) {\n return async (args: OperationArgs = {}, init: RequestOptions = {}) => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const { parseAs, envelope: _envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { response } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (!response.ok) {\n throw new ApiError(\n prepared.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n }\n return {\n page: await parse(response, readKind),\n linkHeader: response.headers.get('link'),\n // Some `Response` implementations leave `url` empty (mocks, constructed responses).\n url: response.url === '' ? prepared.url : response.url,\n };\n };\n}\n\n/**\n * Build a typed instance client over operation descriptors: one real bound method per\n * operation (attached by a construction-time loop — no Proxy), plus the core members\n * (`configure`/`use`/`auth`), which are assigned AFTER the loop so they win any name\n * collision with an operation. All behavior dispatches through the capability seam.\n */\nfunction createClientCore<\n Ops extends OpsShape,\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n>(\n operations: Record,\n initial: ClientConfig> = {},\n caps: Capabilities = {}\n): Client> {\n // The literal-union narrowing is a compile-time DX contract only; internally the\n // runtime works with the base (string-typed) context. One cast at this boundary —\n // `ClientConfig` is not assignable to `ClientConfig` (middleware ctx\n // params are contravariant).\n const given = initial as ClientConfig;\n // Private mutable config; the middleware array is copied so `use()` never mutates the caller's.\n const config: ClientConfig = { ...given, middleware: [...(given.middleware ?? [])] };\n const client = {} as Record;\n\n for (const [name, op] of Object.entries(operations)) {\n if (op.responseKind === 'sse') {\n const method = (given: OperationArgs = {}, init: SseOptions = {}) => {\n const args = inputOf(op, given, config);\n if (!caps.sse) {\n throw new Error(`SSE capability not wired: cannot stream operation \"${op.id}\"`);\n }\n const stream = caps.sse;\n return (async function* () {\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // A thunk the stream re-runs on every (re)connect, so auth (which `prepareRequest`\n // resolves) is refreshed per attempt rather than frozen at the first connect.\n const prepare = async () => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n return { url: prepared.url, init: prepared.init as SseOptions, body: prepared.body };\n };\n yield* stream(config, opCtx, prepare, op.sseDataKind ?? 'text');\n })();\n };\n // Consumers key off the function reference (cache keys, `OPERATIONS[fn.name]`), so\n // each closure carries its operationId instead of an inferred binding name.\n // `operationId` is the explicit, minification-proof form of the same identity\n // (the SPEC operationId — `name` is the emitted key, which a collision may rename).\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n client[name] = method;\n } else {\n // `raw` takes namespaced args; `method` is the public entry that accepts whichever\n // style the client was generated with. The iterators namespace once and then drive\n // `raw`, so a flat call is never converted twice.\n const raw = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n execute(config, op, args, init, caps);\n const method = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n raw(inputOf(op, args, config), init);\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n const spec = op.pagination;\n // Paginated ops keep their one-shot call and gain `.pages`/`.items`, dispatching\n // through the capability seam (like SSE: absent capability throws descriptively).\n // Iteration is error-mode-agnostic: the iterators' pointers are data-rooted, so on\n // a result-mode client (`errorMode` is fixed at construction — `configure()`\n // ignores it) each page's envelope is unwrapped before it reaches the capability.\n // A failed page aborts iteration by throwing ApiError, even on result-mode\n // clients; the `onError` middleware hook (throw-mode-only) is not invoked.\n client[name] =\n spec === undefined\n ? method\n : spec.style === 'link'\n ? Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pagesByLink(\n linkPageCall(config, op, caps),\n inputOf(op, args ?? {}, config),\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).itemsByLink(\n linkPageCall(config, op, caps),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n })\n : Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pages(\n pageCall(raw, config),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).items(\n pageCall(raw, config),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n });\n }\n }\n\n // Core members are assigned AFTER the operation loop — they win over colliding op names.\n client.configure = (next: ClientConfig): void => {\n // `errorMode` and `argsStyle` are fixed at generate time (they shape the static types);\n // flipping either at runtime would silently desync the calls from `Client`, so both\n // are ignored here.\n const { errorMode: _fixedMode, argsStyle: _fixedStyle, auth, ...rest } = next;\n Object.assign(config, rest);\n // `auth` merges into existing credentials (like the `auth.*` setters) rather than\n // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set\n // basic/apiKey. `apiKey` merges per scheme.\n if (auth) {\n config.auth = {\n ...config.auth,\n ...auth,\n ...(auth.apiKey ? { apiKey: { ...config.auth?.apiKey, ...auth.apiKey } } : {}),\n };\n }\n };\n client.use = (...middleware: Middleware[]): void => {\n // Reassign (don't push) so a caller-provided `middleware` array isn't mutated.\n config.middleware = [...(config.middleware ?? []), ...middleware];\n };\n client.auth = {\n bearer(token: TokenProvider): void {\n config.auth = { ...config.auth, bearer: token };\n },\n basic(username: string, password: string): void {\n config.auth = { ...config.auth, basic: { username, password } };\n },\n apiKey(scheme: string, value: TokenProvider): void {\n config.auth = { ...config.auth, apiKey: { ...config.auth?.apiKey, [scheme]: value } };\n },\n };\n\n return client as Client>;\n}", + 'paginate.ts': + "/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `query` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nfunction resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `query[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nasync function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.query?.[spec.param];\n while (true) {\n const query = { ...args.query };\n if (cursor !== undefined) query[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, query }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `query[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.query?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call({ ...args, query: { ...args.query, [spec.param]: position } }, init);\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nasync function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\ntype LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nfunction linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nasync function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let query = args.query;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, query }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n query = { ...args.query, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nasync function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}", + 'cli.ts': + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /**\n * Present when the operation takes a JSON request body. `merged` marks a body whose own\n * properties a flat-style call spells at the top level (the generator decides this from\n * the schema, so the CLI and the client can never disagree).\n */\n body?: { required: boolean; merged?: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n /** `'grouped'` marks a command whose client method takes namespaced inputs even on a\n * flat-style client, because its merged names would collide. */\n argsStyle?: 'grouped';\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n /** The name the CLI is invoked as, for help output only. The generated entry reads it\n * from `process.argv[1]`, so help never names a command that is not installed. */\n name: string;\n /** Credential variable prefix, constant-cased: `CAFE` gives `CAFE_TOKEN`. Fixed at\n * generation from the output file name, so renaming the binary keeps the variables\n * a published CLI already documents. A composed entry sets one per api alias. */\n envPrefix: string;\n /** The generated instance client. */\n client: Record;\n /** How that client takes its inputs. Defaults to `'grouped'`, the generated default. */\n argsStyle?: 'grouped' | 'flat';\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\ntype CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\ntype CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\ntype CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The name to print in help: the command the CLI was invoked as. A global install resolves\n * `argv[1]` to the bin itself, so its basename is exactly what the user typed. A Windows\n * `.cmd` shim, a `node dist/cafe.cli.js`, and a `tsx client.cli.ts` run all pass the script\n * path instead — printing that would name a command nobody can type, so a script extension\n * and the `.cli` marker come off: `cafe.cli.js` prints `cafe`.\n */\nfunction invokedName(scriptPath: string | undefined, fallback: string): string {\n if (scriptPath === undefined) return fallback;\n const base = scriptPath.replace(/^.*[\\\\/]/, '');\n const withoutExtension = base.replace(/\\.(mjs|cjs|js|mts|cts|ts|cmd|bat|ps1|exe)$/i, '');\n const name = withoutExtension.replace(/\\.cli$/i, '');\n return name === '' ? fallback : name;\n}\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/**\n * The parsed argv as one call input, in the style the wired client takes: grouped by layer\n * (the default) or merged into one object.\n */\nfunction callInputs(\n command: CliCommand,\n positionals: Record,\n params: Record,\n body: unknown,\n argsStyle: CliWiring['argsStyle']\n): Record | undefined {\n const inputs: Record = {};\n // A command the generator marked `grouped` keeps the namespaced shape even here.\n if (argsStyle === 'flat' && command.argsStyle !== 'grouped') {\n Object.assign(inputs, positionals, params);\n if (body !== undefined) {\n if (command.body?.merged === true) Object.assign(inputs, body as Record);\n else inputs.body = body;\n }\n } else {\n if (Object.keys(positionals).length > 0) inputs.path = positionals;\n if (Object.keys(params).length > 0) inputs.query = params;\n if (body !== undefined) inputs.body = body;\n }\n return Object.keys(inputs).length > 0 ? inputs : undefined;\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n // An untagged operation is only ever addressed by its bare name, so when that name is also\n // a group slug the name wins — reading it as the group would leave the command unreachable.\n // A tagged operation in the same position keeps yielding to group help: it is still\n // reachable as ` `.\n const untagged = commands.some((c) => c.group === undefined && c.name === argv[0]);\n let command: CliCommand | undefined;\n let rest: string[];\n if (!untagged && slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** `cafe-api` → `CAFE_API`: the casing of every credential variable this CLI reads. */\nfunction constantCase(value: string): string {\n return value\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix;\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${constantCase(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n name: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n name,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${name} ${topic} …`, '', 'Commands:']\n : [`Usage: ${name} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${constantCase(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${name} ${grouped ? ' ' : ''} --help for command details; ${name} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nasync function runCli(sources: CommandSource[], argv: string[]): Promise;\nasync function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.name)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], name: string): string[] {\n const lines = [`Usage: ${name} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${name} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(\n commands,\n wiring.name,\n wiring.schemes ?? [],\n wiring.envPrefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const argument = callInputs(command, positionals, params, body, wiring.argsStyle);\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", } as const; export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES; + +/** Top-level declared names of the runtime modules — precomputed so the pipeline + * builds the reserved-name set without the TypeScript parser. */ +export const RUNTIME_DECLARED_NAMES = [ + 'ApiError', + 'ApiErrorLike', + 'AuthCredentials', + 'BodyMethod', + 'Capabilities', + 'CliAuthScheme', + 'CliCommand', + 'CliFlag', + 'CliGlobals', + 'CliInvocation', + 'CliWiring', + 'Client', + 'ClientConfig', + 'ClientCore', + 'CommandContext', + 'CommandSource', + 'CustomCommand', + 'Envelope', + 'EnvelopeResult', + 'EnvelopeResultForKnownInit', + 'FRAME_DELIMITER', + 'GLOBAL_FLAGS', + 'HeadersOf', + 'IDEMPOTENT_METHODS', + 'LAYERS', + 'LAYER_OF', + 'LinkPageCall', + 'Middleware', + 'NoRequiredKeys', + 'OperationArgs', + 'OperationContext', + 'OperationDescriptor', + 'OperationMethodIdentity', + 'OpsShape', + 'PageOf', + 'Paginated', + 'PaginationSpec', + 'ParamSpec', + 'ParseAs', + 'QueryStyle', + 'QueryValue', + 'RequestContext', + 'RequestOptions', + 'ResolvedCommand', + 'ResponseHeaderSpec', + 'Result', + 'RetryConfig', + 'RetryContext', + 'RetryStrategy', + 'SecuritySpec', + 'SendCapabilities', + 'ServerSentEvent', + 'SseOptions', + 'SseParseError', + 'TRANSIENT_STATUS', + 'ThrowMethod', + 'TimeoutError', + 'TokenProvider', + 'abortError', + 'acceptFor', + 'buildUrl', + 'callInputs', + 'coerceResponseHeader', + 'commandContract', + 'constantCase', + 'createClientCore', + 'defaultRetryOn', + 'encodeBase64', + 'encodeReserved', + 'execute', + 'groupSlug', + 'inputOf', + 'invokedName', + 'isConfigured', + 'items', + 'itemsByLink', + 'kindFor', + 'linkNext', + 'linkPageCall', + 'loadBody', + 'mergeSetup', + 'middlewareChain', + 'namespaceArgs', + 'normalizeCommands', + 'oneLine', + 'pageCall', + 'pages', + 'pagesByLink', + 'paginateCapability', + 'parse', + 'parseInvocation', + 'parseSseFrame', + 'prepareRequest', + 'queryStyles', + 'readEnvelopeHeaders', + 'readError', + 'redactHeaders', + 'renderComposedHelp', + 'renderHelp', + 'resolveAuth', + 'resolvePointer', + 'resolveToken', + 'retryDelay', + 'runCli', + 'runSingle', + 'runSources', + 'send', + 'shadowedCommandName', + 'sleep', + 'splitArgs', + 'sse', + 'stringHeaders', + 'substitutePath', + 'toFormData', + 'toHeaderRecord', +] as const; diff --git a/packages/client-generator/src/emitters/setup-bake.ts b/packages/client-generator/src/emitters/setup-bake.ts index 4eb0d67f1f..a3379a3206 100644 --- a/packages/client-generator/src/emitters/setup-bake.ts +++ b/packages/client-generator/src/emitters/setup-bake.ts @@ -1,5 +1,18 @@ +import ts from 'typescript'; + import { NotSupportedError } from '../errors.js'; -import { ts } from './ts.js'; + +// TypeScript 7 (the native compiler) ships only the tsc binary — none of the compiler API +// this module is built on — yet its package resolves fine, so the first `ts.*` call would +// die with a bare TypeError. Fail with instructions instead. Baking a `--setup` module is +// the ONLY place we parse TypeScript, which is why the dependency is an optional peer. +if (typeof ts?.createSourceFile !== 'function') { + throw new Error( + `Baking a --setup module needs the TypeScript compiler API, but the installed \`typescript\` package` + + `${ts?.version ? ` (${ts.version})` : ''} does not include it — TypeScript 7 ships only the native tsc. ` + + `Install TypeScript 6 for generation (npm i -D typescript@6); your app can still compile the generated client with TypeScript 7.` + ); +} const SETUP_IMPORT = '@redocly/client-generator'; diff --git a/packages/client-generator/src/emitters/sse.ts b/packages/client-generator/src/emitters/sse.ts index b41d70159b..a91d1bbc1e 100644 --- a/packages/client-generator/src/emitters/sse.ts +++ b/packages/client-generator/src/emitters/sse.ts @@ -3,10 +3,6 @@ import type { ResponseBodyModel, SchemaModel, } from '../intermediate-representation/model.js'; -import { ts } from './ts.js'; -import { type DateType, schemaToTypeNode } from './types.js'; - -const { factory } = ts; /** The media type that marks an operation as a Server-Sent Events stream. */ const SSE_CONTENT_TYPE = 'text/event-stream'; @@ -24,7 +20,7 @@ export function isSseOp(op: OperationModel): boolean { } /** The per-event schema: `itemSchema` → the response `schema` → undefined (typeless slots skipped). */ -function eventSchema(op: OperationModel): SchemaModel | undefined { +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; @@ -32,14 +28,6 @@ function eventSchema(op: OperationModel): SchemaModel | undefined { return undefined; } -/** The TS type of a streamed event payload (`string` when no schema is declared). */ -export function sseEventType(op: OperationModel, dateType: DateType): ts.TypeNode { - const schema = eventSchema(op); - return schema - ? schemaToTypeNode(schema, dateType) - : factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); -} - /** 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); diff --git a/packages/client-generator/src/emitters/swr.ts b/packages/client-generator/src/emitters/swr.ts index d9d0901437..4a218db0fc 100644 --- a/packages/client-generator/src/emitters/swr.ts +++ b/packages/client-generator/src/emitters/swr.ts @@ -6,161 +6,69 @@ // or flat `(vars.petId, …, init)`) via the shared `operationSignature`, so the // call type-checks against the generated sdk. // `swr`/`swr/mutation` are the consumer's peer; the sdk stays dependency-free. -// AST-native via `ts.factory`. +// Source-text templates throughout. import type { ApiModel, OperationModel } from '../intermediate-representation/model.js'; import { pascalCase } from './support.js'; -import { - arrow, - constArray, - exportConstStatement as exportConst, - printStatements, - ts, -} from './ts.js'; import { hasInputs, - initParam, isQuery, - sdkCall, - sdkNamedImport, + sdkCallText, + sdkNamedImportText, variablesName, - varsParam, wrappableOperations, } from './wrapper-support.js'; -const { factory } = ts; - export type SwrOptions = { /** Import specifier for the sdk entry the operation functions/types live in. */ sdkModule: string; /** How the sdk function takes its inputs — must match the generated client. */ - argsStyle: 'flat' | 'grouped'; }; /** Render the full SWR module source. `''` when there are no wrappable operations. */ export function renderSwrModule(model: ApiModel, opts: SwrOptions): string { const ops = wrappableOperations(model, 'swr'); if (ops.length === 0) return ''; - return printStatements(swrStatements(ops, opts)); -} - -/** The SWR module statements: the import header followed by per-op hooks. */ -function swrStatements(ops: OperationModel[], opts: SwrOptions): ts.Statement[] { const hasQuery = ops.some(isQuery); const hasMutation = ops.some((op) => !isQuery(op)); - const statements: ts.Statement[] = []; - for (const op of ops) { - statements.push(...(isQuery(op) ? queryStatements(op, opts) : [mutationStatement(op, opts)])); - } - return [...importHeader(ops, opts, hasQuery, hasMutation), ...statements]; + const blocks = [ + ...(hasQuery ? ['import useSWR from "swr";'] : []), + ...(hasMutation ? ['import useSWRMutation from "swr/mutation";'] : []), + sdkNamedImportText(ops, opts.sdkModule, hasQuery), + ...ops.flatMap((op) => (isQuery(op) ? queryBlocks(op) : [mutationBlock(op)])), + ]; + return blocks.join('\n\n'); } -/** An exported `function use() { }` declaration. */ -function exportHook( - op: OperationModel, - params: ts.ParameterDeclaration[], - ret: ts.Expression -): ts.Statement { - const name = `use${pascalCase(op.name)}`; - return factory.createFunctionDeclaration( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - undefined, - name, - undefined, - params, - undefined, - factory.createBlock([factory.createReturnStatement(ret)], true) - ); +/** An exported `function use() { return ; }` declaration. */ +function hookBlock(op: OperationModel, params: string, expr: string): string { + return `export function use${pascalCase(op.name)}(${params}) {\n return ${expr};\n}`; } /** A query op's `Key` factory + `use` hook calling `useSWR`. */ -function queryStatements(op: OperationModel, opts: SwrOptions): ts.Statement[] { +function queryBlocks(op: OperationModel): string[] { const inputs = hasInputs(op); - const keyId = factory.createStringLiteral(op.name); - const keyParams = inputs ? [varsParam(op)] : []; - const keyElements = inputs ? [keyId, factory.createIdentifier('vars')] : [keyId]; - const key = exportConst(`${op.name}Key`, arrow(keyParams, constArray(keyElements))); - - const keyCall = factory.createCallExpression( - factory.createIdentifier(`${op.name}Key`), - undefined, - inputs ? [factory.createIdentifier('vars')] : [] - ); - const useSwr = factory.createCallExpression(factory.createIdentifier('useSWR'), undefined, [ - keyCall, - arrow([], sdkCall(op, opts.argsStyle, 'vars', true)), - ]); - - const params = inputs ? [varsParam(op), initParam()] : [initParam()]; - return [key, exportHook(op, params, useSwr)]; + const keyParams = inputs ? `vars: ${variablesName(op)}` : ''; + const keyElements = inputs + ? `[${JSON.stringify(op.name)}, vars]` + : `[${JSON.stringify(op.name)}]`; + const key = `export const ${op.name}Key = (${keyParams}) => ${keyElements} as const;`; + const keyCall = `${op.name}Key(${inputs ? 'vars' : ''})`; + const useSwr = `useSWR(${keyCall}, () => ${sdkCallText(op, 'vars', true)})`; + // The throw-only `envelope` option is excluded — cached data must stay the plain body. + const params = inputs + ? `vars: ${variablesName(op)}, init?: Omit` + : 'init?: Omit'; + return [key, hookBlock(op, params, useSwr)]; } /** A mutation op's `use` hook calling `useSWRMutation`. */ -function mutationStatement(op: OperationModel, opts: SwrOptions): ts.Statement { - const inputs = hasInputs(op); - const key = factory.createStringLiteral(op.name); - - // `(_key: string, { arg }: { arg: Variables }) => (…arg)` when the op has inputs; - // a no-arg `() => ()` when it has none (`arg` would be unused). - const trigger = inputs - ? triggerWithArg(op, opts) - : arrow([], sdkCall(op, opts.argsStyle, 'arg', false)); - const useSwrMutation = factory.createCallExpression( - factory.createIdentifier('useSWRMutation'), - undefined, - [key, trigger] - ); - return exportHook(op, [], useSwrMutation); -} - -/** `(_key: string, { arg }: { arg: Variables }) => (…arg)`. */ -function triggerWithArg(op: OperationModel, opts: SwrOptions): ts.ArrowFunction { - const keyParam = factory.createParameterDeclaration( - undefined, - undefined, - '_key', - undefined, - factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) - ); - const argParam = factory.createParameterDeclaration( - undefined, - undefined, - factory.createObjectBindingPattern([factory.createBindingElement(undefined, undefined, 'arg')]), - undefined, - factory.createTypeLiteralNode([ - factory.createPropertySignature( - undefined, - 'arg', - undefined, - factory.createTypeReferenceNode(variablesName(op)) - ), - ]) - ); - return arrow([keyParam, argParam], sdkCall(op, opts.argsStyle, 'arg', false)); -} - -/** - * The import header: `useSWR` from `swr` (when any query op), `useSWRMutation` from - * `swr/mutation` (when any mutation op), then the shared sdk named import. - */ -function importHeader( - ops: OperationModel[], - opts: SwrOptions, - hasQuery: boolean, - hasMutation: boolean -): ts.Statement[] { - const imports: ts.Statement[] = []; - if (hasQuery) imports.push(defaultImport('useSWR', 'swr')); - if (hasMutation) imports.push(defaultImport('useSWRMutation', 'swr/mutation')); - imports.push(sdkNamedImport(ops, opts.sdkModule, hasQuery)); - return imports; -} - -/** `import from "";` (default import). */ -function defaultImport(name: string, module: string): ts.Statement { - return factory.createImportDeclaration( - undefined, - factory.createImportClause(false, factory.createIdentifier(name), undefined), - factory.createStringLiteral(module) - ); +function mutationBlock(op: OperationModel): string { + // `(_key: string, { arg }: { arg: Variables }) => (…arg)` when the op has + // inputs; a no-arg `() => ()` when it has none (`arg` would be unused). + const trigger = hasInputs(op) + ? `(_key: string, { arg }: {\n arg: ${variablesName(op)};\n }) => ${sdkCallText(op, 'arg', false)}` + : `() => ${sdkCallText(op, 'arg', false)}`; + const useSwrMutation = `useSWRMutation(${JSON.stringify(op.name)}, ${trigger})`; + return hookBlock(op, '', useSwrMutation); } diff --git a/packages/client-generator/src/emitters/tanstack-query.ts b/packages/client-generator/src/emitters/tanstack-query.ts index d285c0bde4..4e0a43fc06 100644 --- a/packages/client-generator/src/emitters/tanstack-query.ts +++ b/packages/client-generator/src/emitters/tanstack-query.ts @@ -6,11 +6,10 @@ // TanStack's abort `signal`. A paginated query op additionally gets // `InfiniteOptions(vars, init?)` with `initialPageParam`/`getNextPageParam` compiled // from the pagination rule's JSON pointers. Per mutation: `Mutation(init?)`. Calls go -// through the client instance's grouped methods, so the module is independent of the -// sdk's `--args-style`. +// through the client instance's methods, which take one input object in either +// `--args-style`; only the infinite query's cursor override differs between them. // -// The factory bodies are authored as source text and round-tripped through -// `parseStatements` → `printStatements`, which validates the syntax at generation time +// The factory bodies are authored as source text — the emitted module verbatim // and normalizes everything to the printer's canonical style. Every interpolated piece // is generator-derived (sanitized operation names, JSON-pointer property chains built // here) — never raw spec text. @@ -24,7 +23,6 @@ import { resolveModelPagination, resolveSchemaPointer, } from './pagination.js'; -import { parseStatements, printStatements } from './ts.js'; import { hasInputs, isQuery, variablesName, wrappableOperations } from './wrapper-support.js'; export type TanstackOptions = { @@ -37,6 +35,8 @@ export type TanstackOptions = { /** Leading element for every query/mutation key — namespaces the cache when several * generated APIs share one QueryClient (operationIds may collide across APIs). */ queryKeyPrefix?: string; + /** The sdk's call shape — the infinite query overrides the cursor inside it. */ + argsStyle?: 'grouped' | 'flat'; }; /** Render the full TanStack Query module source. `''` when there are no wrappable operations. */ @@ -47,10 +47,10 @@ export function renderTanstackModule(model: ApiModel, opts: TanstackOptions): st const source = [ importHeader(ops, opts, pagination), ...ops.filter(isQuery).map((op) => queryKeySource(op, opts.queryKeyPrefix)), - factoriesSource(model, ops, pagination, opts.queryKeyPrefix), + factoriesSource(model, ops, pagination, opts.queryKeyPrefix, opts.argsStyle), ...defaultBindings(ops, pagination), ].join('\n'); - return printStatements(parseStatements(source)); + return source; } /** @@ -116,20 +116,21 @@ function factoriesSource( model: ApiModel, ops: OperationModel[], pagination: ModelPagination, - prefix: string | undefined + prefix: string | undefined, + argsStyle: TanstackOptions['argsStyle'] ): string { const members = ops.flatMap((op) => { if (!isQuery(op)) return [mutationMember(op, prefix)]; const paginated = pagination.get(op.name); return paginated !== undefined && paginated.spec.style !== 'link' - ? [optionsMember(op), infiniteMember(model, op, paginated.spec)] + ? [optionsMember(op), infiniteMember(model, op, paginated.spec, argsStyle)] : [optionsMember(op)]; }); return ( '/**\n' + ' * Build the factories over a specific client instance — its config, middleware, and\n' + ' * retry apply to every call (`createQueryFactories(createClient(OPERATIONS, config))`).\n' + - " * The module-level exports below are these factories bound to the sdk's default `client`.\n" + + " * The module-level exports below are these factories bound to the generated module's default `client`.\n" + ' */\n' + 'export const createQueryFactories = (instance: typeof client = client) => ({\n' + members.join(',\n') + @@ -171,15 +172,22 @@ function mutationMember(op: OperationModel, prefix: string | undefined): string function infiniteMember( model: ApiModel, op: OperationModel, - spec: Exclude + spec: Exclude, + argsStyle: TanstackOptions['argsStyle'] ): string { const { params, keyArg } = varsPieces(op); - const override = `{ ...vars, params: { ...vars.params, ${safeIdent(spec.param)}: pageParam } }`; + // The cursor is a query parameter, so it lands in the sdk's own spelling for one: + // inside the `query` layer, or at the top level of a merged call. + const cursor = safeIdent(spec.param); + const override = + argsStyle === 'flat' + ? `{ ...vars, ${cursor}: pageParam }` + : `{ ...vars, query: { ...vars.query, ${cursor}: pageParam } }`; return ( ` ${op.name}InfiniteOptions: (${params}) => infiniteQueryOptions({\n` + ` queryKey: [...${op.name}QueryKey(${keyArg}), "infinite"] as const,\n` + ` queryFn: ({ pageParam, signal }) => instance.${op.name}(${override}, { ...init, signal, envelope: undefined }),\n` + - nextPageSource(model, op, spec) + + nextPageSource(model, op, spec, argsStyle) + ` })` ); } @@ -188,9 +196,12 @@ function infiniteMember( function nextPageSource( model: ApiModel, op: OperationModel, - spec: Exclude + spec: Exclude, + argsStyle: TanstackOptions['argsStyle'] ): string { const advance = paramsAccess(spec.param); + // Where the caller's own starting value lives, in the sdk's spelling for a query param. + const given = argsStyle === 'flat' ? memberAccess('vars', spec.param) : `vars.query?.${advance}`; if (spec.style === 'cursor') { const stopEarly = spec.hasMore === undefined @@ -207,7 +218,7 @@ function nextPageSource( : ` const next = lastPage${pointerChain(spec.nextCursor)};\n` + ` return ${checks.join(' || ')} ? undefined : next;\n`; return ( - ` initialPageParam: vars.params?.${advance},\n` + + ` initialPageParam: ${given},\n` + ` getNextPageParam: (lastPage) => {\n` + stopEarly + body + @@ -217,7 +228,7 @@ function nextPageSource( const step = spec.style === 'offset' ? 'lastPageParam + count' : 'lastPageParam + 1'; const start = spec.style === 'offset' ? '0' : '1'; return ( - ` initialPageParam: vars.params?.${advance} ?? ${start},\n` + + ` initialPageParam: ${given} ?? ${start},\n` + ` getNextPageParam: (lastPage, _allPages, lastPageParam) => {\n` + ` const count = ${itemsLength(spec.items)};\n` + ` return count === 0 ? undefined : ${step};\n` + @@ -279,6 +290,14 @@ function paramsAccess(name: string): string { return isSafeIdentifier(name) ? name : `[${safeIdent(name)}]`; } +/** + * `.name`, or `["wire-name"]` when the name is not an identifier — the dot form + * would be a syntax error there. (After `?.` either form appends directly.) + */ +function memberAccess(base: string, name: string): string { + return isSafeIdentifier(name) ? `${base}.${name}` : `${base}[${safeIdent(name)}]`; +} + /** An RFC 6901 pointer as an optional property chain: `/page/endCursor` → `.page?.endCursor`. */ function pointerChain(pointer: string): string { const keys = pointer diff --git a/packages/client-generator/src/emitters/transformers.ts b/packages/client-generator/src/emitters/transformers.ts index d76258c3dd..b913f91fcf 100644 --- a/packages/client-generator/src/emitters/transformers.ts +++ b/packages/client-generator/src/emitters/transformers.ts @@ -6,8 +6,8 @@ // // Pairs with the sdk generated under `dateType: 'Date'`; the client itself // stays zero-dep (Date is standard). Transformers compose across refs: -// `transformPet` calls `transformPerson(data["owner"])` when `Pet.owner` is a -// `Person` that has dates. +// `transformPet` calls `transformOwner(data["owner"])` when `Pet.owner` is an +// `Owner` that has dates. Source-text templates throughout. import type { ApiModel, @@ -16,9 +16,8 @@ import type { } from '../intermediate-representation/model.js'; import { safeIdent } from './identifier.js'; import { pascalCase } from './support.js'; -import { arrow, exportConstStatement, parseStatements, printStatements, ts } from './ts.js'; -const { factory } = ts; +const INDENT = ' '; /** `transform` — the function bound to a named schema. */ function transformName(name: string): string { @@ -33,6 +32,22 @@ const WRITABLE_DECL = 'type __Writable = { -readonly [K in keyof T]: T[K] };' /** Set by `writableLhs` during a render; `renderTransformersModule` resets and reads it. */ let writableUsed = false; +/** + * A write target: the rendered expression plus its access path (base identifier + * followed by string keys), so the `readonly` cast can rebuild the + * `NonNullable` chain. Loop variables have a bare one-segment path. + */ +type Target = { text: string; path: string[] }; + +function ident(name: string): Target { + return { text: name, path: [name] }; +} + +/** `["key"]` — bracket access, robust for any (incl. non-identifier) key. */ +function index(target: Target, key: string): Target { + return { text: `${target.text}[${JSON.stringify(key)}]`, path: [...target.path, key] }; +} + /** * Whether transforming a value of `schema` REPLACES it (so the result must be * assigned back) rather than mutating it in place: date scalars, arrays of @@ -101,67 +116,29 @@ function hasDates( } } -/** `["key"]` — bracket access, robust for any (incl. non-identifier) key. */ -function index(target: ts.Expression, key: string): ts.ElementAccessExpression { - return factory.createElementAccessExpression(target, factory.createStringLiteral(key)); -} - -/** `new Date()`. */ -function newDate(arg: ts.Expression): ts.Expression { - return factory.createNewExpression(factory.createIdentifier('Date'), undefined, [arg]); -} - -/** `typeof === "string"`. */ -function isStringGuard(expr: ts.Expression): ts.Expression { - return factory.createBinaryExpression( - factory.createTypeOfExpression(expr), - factory.createToken(ts.SyntaxKind.EqualsEqualsEqualsToken), - factory.createStringLiteral('string') - ); -} - -/** `Array.isArray()`. */ -function isArrayGuard(expr: ts.Expression): ts.Expression { - return factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('Array'), 'isArray'), - undefined, - [expr] - ); -} - -/** ` && typeof === "object"` — truthy and a (non-null) object. */ -function isObjectGuard(expr: ts.Expression): ts.Expression { - return factory.createBinaryExpression( - expr, - factory.createToken(ts.SyntaxKind.AmpersandAmpersandToken), - factory.createBinaryExpression( - factory.createTypeOfExpression(expr), - factory.createToken(ts.SyntaxKind.EqualsEqualsEqualsToken), - factory.createStringLiteral('object') - ) - ); -} - -/** ` as ` — a type assertion, to satisfy a union-narrowing transform. */ -function asType(expr: ts.Expression, typeName: string): ts.Expression { - return factory.createAsExpression(expr, factory.createTypeReferenceNode(typeName)); -} - -/** `if () ;`. */ -function ifThen(cond: ts.Expression, then: ts.Statement): ts.Statement { - return factory.createIfStatement(cond, then); +/** + * `if () …` — a brace-less single-statement `then` prints on the next line one + * level deeper (`block: false`), a braced one wraps in `{ … }` (`block: true`); + * `then` receives the indent its lines must start at. + */ +function ifThen( + cond: string, + then: (indent: string) => string[], + indent: string, + block = false +): string[] { + if (block) return [`${indent}if (${cond}) {`, ...then(indent + INDENT), `${indent}}`]; + return [`${indent}if (${cond})`, ...then(indent + INDENT)]; } -function exprStatement(expr: ts.Expression): ts.Statement { - return factory.createExpressionStatement(expr); +/** ` = ;` — the LHS cast writable when it is a `readonly` property. */ +function assign(target: Target, value: string, readonlyLhs = false): (indent: string) => string[] { + const lhs = readonlyLhs ? writableLhs(target) : target.text; + return (indent) => [`${indent}${lhs} = ${value};`]; } -/** ` = ;` — the LHS cast writable when it is a `readonly` property. */ -function assign(target: ts.Expression, value: ts.Expression, readonlyLhs = false): ts.Statement { - const lhs = readonlyLhs ? writableLhs(target) : target; - return exprStatement( - factory.createBinaryExpression(lhs, factory.createToken(ts.SyntaxKind.EqualsToken), value) - ); +function statement(expr: string): (indent: string) => string[] { + return (indent) => [`${indent}${expr};`]; } /** @@ -169,51 +146,28 @@ function assign(target: ts.Expression, value: ts.Expression, readonlyLhs = false * `(recv as __Writable>)["key"]`. `readonly` is shallow — * it blocks only the direct assignment — so nested writes stay uncast. */ -function writableLhs(lhs: ts.Expression): ts.Expression { - if (!ts.isElementAccessExpression(lhs)) return lhs; // a parameter reassignment is never readonly +function writableLhs(target: Target): string { + if (target.path.length < 2) return target.text; // a parameter reassignment is never readonly writableUsed = true; - const receiver = factory.createParenthesizedExpression( - factory.createAsExpression( - lhs.expression, - factory.createTypeReferenceNode('__Writable', [nonNullTypeOf(lhs.expression)]) - ) - ); - return factory.createElementAccessExpression(receiver, lhs.argumentExpression); + const receiver: Target = { + text: target.text.slice(0, target.text.lastIndexOf('[')), + path: target.path.slice(0, -1), + }; + const key = target.path[target.path.length - 1]; + return `(${receiver.text} as __Writable<${nonNullTypeOf(receiver)}>)[${JSON.stringify(key)}]`; } /** - * `NonNullable>` for the expression chains this emitter builds - * (an identifier indexed by string-literal keys), with `NonNullable` applied at - * every step so optional intermediate properties don't poison the indexed type. + * `NonNullable>` for the access paths this emitter builds, with + * `NonNullable` applied at every step so optional intermediate properties don't + * poison the indexed type. */ -function nonNullTypeOf(expr: ts.Expression): ts.TypeNode { - let base: ts.TypeNode; - if (ts.isIdentifier(expr)) { - base = factory.createTypeQueryNode(expr); - } else if (ts.isElementAccessExpression(expr) && ts.isStringLiteral(expr.argumentExpression)) { - base = factory.createIndexedAccessTypeNode( - nonNullTypeOf(expr.expression), - factory.createLiteralTypeNode(factory.createStringLiteral(expr.argumentExpression.text)) - ); - } else { - // Every write target is built here from `data`/loop identifiers + string-literal - // element access (`index`), so any other shape is an emitter bug. - throw new Error('transformers: unsupported write-target expression'); +function nonNullTypeOf(target: Target): string { + let type = `typeof ${target.path[0]}`; + for (const key of target.path.slice(1)) { + type = `NonNullable<${type}>[${JSON.stringify(key)}]`; } - return factory.createTypeReferenceNode('NonNullable', [base]); -} - -/** `.()`. */ -function method(recv: ts.Expression, name: string, args: ts.Expression[]): ts.Expression { - return factory.createCallExpression( - factory.createPropertyAccessExpression(recv, name), - undefined, - args - ); -} - -function param(name: string): ts.ParameterDeclaration { - return factory.createParameterDeclaration(undefined, undefined, name); + return `NonNullable<${type}>`; } /** Next nested loop variable: `item`, `item2`, `item3`, … (avoids shadowing). */ @@ -223,61 +177,62 @@ function nextItemVar(current: string): string { } /** - * Conversion statements that, given the runtime value at `target` typed by - * `schema`, rewrite date leaves in place. Each branch self-gates by returning - * `[]` when nothing under it carries a date, so callers need no pre-check. - * `seen` follows refs and guards cycles; `itemVar` names nested loop variables. - * - * Covers the shapes a date can hide in: date scalars, arrays of them, refs to - * date-bearing schemas (composed via `transform`), arrays of such refs, - * records, nested inline objects, and the date-bearing members of a - * union/intersection. + * Conversion lines that, given the runtime value at `target` typed by `schema`, + * rewrite date leaves in place. Each branch self-gates by returning `[]` when + * nothing under it carries a date, so callers need no pre-check. `seen` follows + * refs and guards cycles; `itemVar` names nested loop variables. */ function convert( - target: ts.Expression, + target: Target, schema: SchemaModel, byName: Map, seen: Set, itemVar: string, + indent: string, readonlyLhs = false -): ts.Statement[] { +): string[] { if (isDateScalar(schema)) { - return [ifThen(isStringGuard(target), assign(target, newDate(target), readonlyLhs))]; + return ifThen( + `typeof ${target.text} === "string"`, + assign(target, `new Date(${target.text})`, readonlyLhs), + indent + ); } switch (schema.kind) { case 'ref': - return convertRef(target, schema.name, byName, seen, readonlyLhs); + return convertRef(target, schema.name, byName, seen, indent, readonlyLhs); case 'object': { - const stmts: ts.Statement[] = []; + const lines: string[] = []; for (const p of schema.properties) { - stmts.push( + lines.push( ...convertProperty( index(target, p.name), p.schema, byName, seen, itemVar, + indent, p.readOnly === true ) ); } - return stmts; + return lines; } case 'array': - return convertArray(target, schema.items, byName, seen, itemVar, readonlyLhs); + return convertArray(target, schema.items, byName, seen, itemVar, indent, readonlyLhs); case 'record': - return convertCollection(target, schema.value, byName, seen, itemVar, true); + return convertCollection(target, schema.value, byName, seen, itemVar, indent, true); case 'intersection': { // An intersection value satisfies *every* member type, so each member's // transform applies directly to `target` with no narrowing needed. - const stmts: ts.Statement[] = []; + const lines: string[] = []; for (const m of schema.members) { - stmts.push(...convert(target, m, byName, seen, itemVar, readonlyLhs)); + lines.push(...convert(target, m, byName, seen, itemVar, indent, readonlyLhs)); } - return stmts; + return lines; } case 'union': - return convertUnion(target, schema.members, byName, seen, itemVar, readonlyLhs); + return convertUnion(target, schema.members, byName, seen, itemVar, indent, readonlyLhs); default: return []; } @@ -298,39 +253,45 @@ function convert( * `--date-type Date`, so the assignment type-checks). */ function convertUnion( - target: ts.Expression, + target: Target, members: SchemaModel[], byName: Map, seen: Set, itemVar: string, + indent: string, readonlyLhs = false -): ts.Statement[] { - const stmts: ts.Statement[] = []; - const objectGuarded: ts.Statement[] = []; +): string[] { + const lines: string[] = []; + const guardedIndent = indent + INDENT; + const objectGuarded: string[] = []; for (const m of members) { if (isDateScalar(m)) { - stmts.push(...convert(target, m, byName, seen, itemVar, readonlyLhs)); + lines.push(...convert(target, m, byName, seen, itemVar, indent, readonlyLhs)); } else if (m.kind === 'ref') { if (!hasDates(m, byName, seen)) continue; - const call = factory.createCallExpression( - factory.createIdentifier(transformName(m.name)), - undefined, - [asType(target, m.name)] - ); + const call = `${transformName(m.name)}(${target.text} as ${m.name})`; // A replace-by-value ref (scalar dates) must be assigned back; an object // ref mutates in place, so its return can be dropped. - objectGuarded.push( - needsReassign(m, byName, seen) ? assign(target, call, readonlyLhs) : exprStatement(call) - ); + const build = needsReassign(m, byName, seen) + ? assign(target, call, readonlyLhs) + : statement(call); + objectGuarded.push(...build(guardedIndent)); } else { // Object/array/record members: recurse under the shared object guard. - objectGuarded.push(...convert(target, m, byName, seen, itemVar, readonlyLhs)); + objectGuarded.push(...convert(target, m, byName, seen, itemVar, guardedIndent, readonlyLhs)); } } if (objectGuarded.length > 0) { - stmts.push(ifThen(isObjectGuard(target), factory.createBlock(objectGuarded, true))); + lines.push( + ...ifThen( + `${target.text} && typeof ${target.text} === "object"`, + () => objectGuarded, + indent, + true + ) + ); } - return stmts; + return lines; } /** @@ -341,25 +302,21 @@ function convertUnion( * assigned back: `if () = transform();`. */ function convertRef( - target: ts.Expression, + target: Target, name: string, byName: Map, seen: Set, + indent: string, readonlyLhs = false -): ts.Statement[] { +): string[] { const ref: SchemaModel = { kind: 'ref', name }; if (!hasDates(ref, byName, seen)) return []; - const call = factory.createCallExpression( - factory.createIdentifier(transformName(name)), - undefined, - [target] + const call = `${transformName(name)}(${target.text})`; + return ifThen( + target.text, + needsReassign(ref, byName, seen) ? assign(target, call, readonlyLhs) : statement(call), + indent ); - return [ - ifThen( - target, - needsReassign(ref, byName, seen) ? assign(target, call, readonlyLhs) : exprStatement(call) - ), - ]; } /** @@ -368,20 +325,24 @@ function convertRef( * `convert`, which guards itself. */ function convertProperty( - target: ts.Expression, + target: Target, schema: SchemaModel, byName: Map, seen: Set, itemVar: string, + indent: string, readonlyLhs = false -): ts.Statement[] { - if (schema.kind === 'ref') return convertRef(target, schema.name, byName, seen, readonlyLhs); +): string[] { + if (schema.kind === 'ref') { + return convertRef(target, schema.name, byName, seen, indent, readonlyLhs); + } if (schema.kind === 'object') { // Nested writes go one level inside — `readonly` is shallow, so no cast needed. - const inner = convert(target, schema, byName, seen, itemVar); - return inner.length === 0 ? [] : [ifThen(target, factory.createBlock(inner, true))]; + const inner = convert(target, schema, byName, seen, itemVar, indent + INDENT); + if (inner.length === 0) return []; + return ifThen(target.text, () => inner, indent, true); } - return convert(target, schema, byName, seen, itemVar, readonlyLhs); + return convert(target, schema, byName, seen, itemVar, indent, readonlyLhs); } /** @@ -390,43 +351,28 @@ function convertProperty( * such elements (`v.map(...)`). Returns the expression that yields the replaced * value for the element bound to `value`, or `null` when the element instead * mutates in place (object/ref/record). Recurses for arrays-of-arrays. - * - * Reassigning a loop *variable* is a no-op, so date scalars (and arrays of - * them) can only be converted by reassigning their container slot — an array - * via `slot = slot.map(...)`, a record via per-key assignment. This builds the - * per-element value for those write-backs. */ function replacer( - value: ts.Expression, + value: string, element: SchemaModel, byName: Map, seen: Set, depth = 0 -): ts.Expression | null { - if (isDateScalar(element)) return newDate(value); +): string | null { + if (isDateScalar(element)) return `new Date(${value})`; // A ref resolving to a replace-by-value shape (a scalar-date named schema): // its sibling transform returns the converted value — `transform(v)`. if (element.kind === 'ref' && needsReassign(element, byName, seen)) { - return factory.createCallExpression( - factory.createIdentifier(transformName(element.name)), - undefined, - [value] - ); + return `${transformName(element.name)}(${value})`; } if (element.kind === 'array') { // Map var for the level below: `v` over the scalar leaf, else `row`, `row2`, // … per array level — distinct names by depth avoid shadowing. Yields - // `.map((v) => new Date(v))` and `.map((row) => row.map((v) => new Date(v)))`. + // `.map(v => new Date(v))` and `.map(row => row.map(v => new Date(v)))`. const varName = element.items.kind === 'array' ? rowVar(depth + 1) : 'v'; - const inner = replacer( - factory.createIdentifier(varName), - element.items, - byName, - seen, - depth + 1 - ); + const inner = replacer(varName, element.items, byName, seen, depth + 1); if (inner === null) return null; - return method(value, 'map', [arrow([param(varName)], inner)]); + return `${value}.map(${varName} => ${inner})`; } return null; } @@ -438,35 +384,36 @@ function rowVar(depth: number): string { /** Conversions for `target` being an array whose elements are typed by `items`. */ function convertArray( - target: ts.Expression, + target: Target, items: SchemaModel, byName: Map, seen: Set, itemVar: string, + indent: string, readonlyLhs = false -): ts.Statement[] { +): string[] { // Date scalars / arrays-of-date-scalars are replace-by-value: map over the // array and reassign the slot (reassigning a loop var would be lost). const varName = items.kind === 'array' ? rowVar(1) : 'v'; - const mapped = replacer(factory.createIdentifier(varName), items, byName, seen, 1); + const mapped = replacer(varName, items, byName, seen, 1); if (mapped !== null) { - // `if (Array.isArray(t)) t = t.map((v) => new Date(v));` (or nested `row`) - return [ - ifThen( - isArrayGuard(target), - assign(target, method(target, 'map', [arrow([param(varName)], mapped)]), readonlyLhs) - ), - ]; + // `if (Array.isArray(t)) t = t.map(v => new Date(v));` (or nested `row`) + return ifThen( + `Array.isArray(${target.text})`, + assign(target, `${target.text}.map(${varName} => ${mapped})`, readonlyLhs), + indent + ); } if (items.kind === 'ref') { if (!hasDates(items, byName, seen)) return []; // `if (Array.isArray(t)) t.forEach(transformRef);` - const forEach = method(target, 'forEach', [ - factory.createIdentifier(transformName(items.name)), - ]); - return [ifThen(isArrayGuard(target), exprStatement(forEach))]; + return ifThen( + `Array.isArray(${target.text})`, + statement(`${target.text}.forEach(${transformName(items.name)})`), + indent + ); } - return convertCollection(target, items, byName, seen, itemVar, false); + return convertCollection(target, items, byName, seen, itemVar, indent, false); } /** @@ -478,102 +425,64 @@ function convertArray( * Replace-by-value elements (date scalars) never reach the array path here — * `convertArray` handles them via map-and-reassign. A *record* of date scalars * does land here: a `forEach` loop variable can't write back, so we iterate the - * keys and assign back into the record (`rec[k] = new Date(rec[k])`). + * keys and assign back into the record (`rec[__k] = new Date(rec[__k])`). */ function convertCollection( - target: ts.Expression, + target: Target, element: SchemaModel, byName: Map, seen: Set, itemVar: string, + indent: string, isRecord: boolean -): ts.Statement[] { +): string[] { if (isRecord) { // Replace-by-value elements (date scalars, arrays of them) can't be written // through a `forEach` loop var, so iterate the keys and assign back into the // record slot. Date scalars are string-guarded; nested arrays array-guarded. - const slot = factory.createElementAccessExpression(target, factory.createIdentifier('__k')); - const replaced = replacer(slot, element, byName, seen); + const slot: Target = { text: `${target.text}[__k]`, path: [...target.path, '__k'] }; + const replaced = replacer(slot.text, element, byName, seen); if (replaced !== null) { - const guard = isDateScalar(element) ? isStringGuard(slot) : isArrayGuard(slot); - return [ifThen(target, keyLoop(target, ifThen(guard, assign(slot, replaced))))]; + const guard = isDateScalar(element) + ? `typeof ${slot.text} === "string"` + : `Array.isArray(${slot.text})`; + return ifThen( + target.text, + (loopIndent) => [ + `${loopIndent}for (const __k of Object.keys(${target.text}))`, + ...ifThen(guard, (inner) => [`${inner}${slot.text} = ${replaced};`], loopIndent + INDENT), + ], + indent + ); } } const next = nextItemVar(itemVar); - const body = convert(factory.createIdentifier(next), element, byName, seen, next); + // The loop sits one `if` level in, and the forEach body one more. + const body = convert(ident(next), element, byName, seen, next, indent + INDENT + INDENT); if (body.length === 0) return []; - const iterable = isRecord - ? method(factory.createIdentifier('Object'), 'values', [target]) - : target; - const forEach = method(iterable, 'forEach', [ - arrow([param(next)], factory.createBlock(body, true)), - ]); - return [ifThen(isRecord ? target : isArrayGuard(target), exprStatement(forEach))]; -} - -/** `for (const __k of Object.keys()) `. */ -function keyLoop(target: ts.Expression, body: ts.Statement): ts.Statement { - return factory.createForOfStatement( - undefined, - factory.createVariableDeclarationList( - [factory.createVariableDeclaration('__k')], - ts.NodeFlags.Const - ), - method(factory.createIdentifier('Object'), 'keys', [target]), - body + const iterable = isRecord ? `Object.values(${target.text})` : target.text; + return ifThen( + isRecord ? target.text : `Array.isArray(${target.text})`, + (inner) => [`${inner}${iterable}.forEach(${next} => {`, ...body, `${inner}});`], + indent ); } /** `export const transform = (data: ): => { … };`. */ -function transformStatement( - named: NamedSchemaModel, - byName: Map -): ts.Statement { +function transformBlock(named: NamedSchemaModel, byName: Map): string { // The sdk exports the type verbatim; only the `transform` NAME is PascalCased. const typeName = named.name; - const data = factory.createIdentifier('data'); + const data = ident('data'); const body = named.schema.kind === 'ref' - ? convertRef(data, named.schema.name, byName, new Set()) - : convert(data, named.schema, byName, new Set(), 'data'); - const fn = arrow( - [ - factory.createParameterDeclaration( - undefined, - undefined, - 'data', - undefined, - factory.createTypeReferenceNode(typeName) - ), - ], - factory.createBlock([...body, factory.createReturnStatement(data)], true) - ); - const typed = factory.createArrowFunction( - fn.modifiers, - fn.typeParameters, - fn.parameters, - factory.createTypeReferenceNode(typeName), - fn.equalsGreaterThanToken, - fn.body - ); - return exportConstStatement(transformName(named.name), typed); -} - -/** `import type { , … } from "";`. */ -function typeImport(names: string[], module: string): ts.Statement { - return factory.createImportDeclaration( - undefined, - factory.createImportClause( - true, - undefined, - factory.createNamedImports( - names.map((n) => - factory.createImportSpecifier(false, undefined, factory.createIdentifier(safeIdent(n))) - ) - ) - ), - factory.createStringLiteral(module) - ); + ? convertRef(data, named.schema.name, byName, new Set(), INDENT) + : convert(data, named.schema, byName, new Set(), 'data', INDENT); + return [ + `export const ${transformName(named.name)} = (data: ${typeName}): ${typeName} => {`, + ...body, + `${INDENT}return data;`, + '};', + ].join('\n'); } /** @@ -586,13 +495,13 @@ export function renderTransformersModule(model: ApiModel, opts: { sdkModule: str const byName = new Map(model.schemas.map((s) => [s.name, s.schema])); const dated = model.schemas.filter((s) => hasDates(s.schema, byName, new Set())); if (dated.length === 0) return ''; - const types = dated.map((s) => s.name); + const types = dated.map((s) => safeIdent(s.name)).join(', '); writableUsed = false; // reset the per-render flag `writableLhs` sets - const transforms = dated.map((s) => transformStatement(s, byName)); - const statements = [ - typeImport(types, opts.sdkModule), - ...(writableUsed ? parseStatements(WRITABLE_DECL) : []), + const transforms = dated.map((s) => transformBlock(s, byName)); + const blocks = [ + `import type { ${types} } from ${JSON.stringify(opts.sdkModule)};`, + ...(writableUsed ? [WRITABLE_DECL] : []), ...transforms, ]; - return printStatements(statements); + return blocks.join('\n\n'); } diff --git a/packages/client-generator/src/emitters/ts-literal.ts b/packages/client-generator/src/emitters/ts-literal.ts new file mode 100644 index 0000000000..86008af0b9 --- /dev/null +++ b/packages/client-generator/src/emitters/ts-literal.ts @@ -0,0 +1,38 @@ +// 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/ts-type.ts b/packages/client-generator/src/emitters/ts-type.ts new file mode 100644 index 0000000000..27f410fe2e --- /dev/null +++ b/packages/client-generator/src/emitters/ts-type.ts @@ -0,0 +1,164 @@ +// TypeScript TYPES as source text: pure string logic over the IR, no +// `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'; + +const INDENT = ' '; + +/** A JSDoc block (description + metadata tags) as indented lines, or [] when empty. */ +export function tsJsdoc( + text: string | undefined, + metadata: SchemaMetadata | undefined, + indent: string +): string[] { + const body = jsdocText(text, metadata); + if (body === undefined) return []; + return [ + `${indent}/**`, + ...escapeJsDoc(body) + .split('\n') + .map((line) => `${indent} * ${line}`.replace(/ +$/, '')), + `${indent} */`, + ]; +} + +function literalType(value: string | number | boolean): string { + return typeof value === 'string' ? JSON.stringify(value) : String(value); +} + +function scalarType( + kind: ScalarKind, + metadata: SchemaMetadata | undefined, + dateType: DateType +): string { + switch (kind) { + case 'string': + if (metadata?.format === 'binary') return 'Blob'; + if ( + dateType === 'Date' && + (metadata?.format === 'date-time' || metadata?.format === 'date') + ) { + return 'Date'; + } + return 'string'; + case 'number': + case 'integer': + return 'number'; + case 'boolean': + return 'boolean'; + } +} + +/** True when the rendered type needs parentheses as an array element / intersection member. */ +function isCompound(schema: SchemaModel): boolean { + return ( + schema.kind === 'union' || + schema.kind === 'intersection' || + (schema.kind === 'enum' && schema.values.length > 1) + ); +} + +/** The TypeScript type for an IR schema, rendered at `indent` (the containing line's indent). */ +export function tsType(schema: SchemaModel, dateType: DateType = 'string', indent = ''): string { + switch (schema.kind) { + case 'scalar': + return scalarType(schema.scalar, schema.metadata, dateType); + case 'ref': + return schema.name; + case 'literal': + return literalType(schema.value); + case 'enum': + return schema.values.map(literalType).join(' | '); + case 'null': + return 'null'; + case 'unknown': + return 'unknown'; + case 'array': { + const element = tsType(schema.items, dateType, indent); + return isCompound(schema.items) ? `(${element})[]` : `${element}[]`; + } + case 'record': + return `Record`; + case 'object': { + if (schema.properties.length === 0) return '{}'; + const inner = indent + INDENT; + const lines = schema.properties.flatMap((property) => + propertyLines(property, dateType, inner) + ); + return `{\n${lines.join('\n')}\n${indent}}`; + } + case 'union': + return schema.members + .map((member) => { + const rendered = tsType(member, dateType, indent); + return isCompound(member) ? `(${rendered})` : rendered; + }) + .join(' | '); + case 'intersection': + return schema.members + .map((member) => { + const rendered = tsType(member, dateType, indent); + return isCompound(member) ? `(${rendered})` : rendered; + }) + .join(' & '); + case 'omit': + return `Omit<${schema.base}, ${schema.keys.map((key) => JSON.stringify(key)).join(' | ')}>`; + } +} + +function propertyLines(property: PropertyModel, dateType: DateType, indent: string): string[] { + const name = safeIdent(property.name); + const readonly = property.readOnly ? 'readonly ' : ''; + const optional = property.required ? '' : '?'; + const type = tsType(property.schema, dateType, indent); + return [ + ...tsJsdoc(property.description, property.schema.metadata, indent), + `${indent}${readonly}${name}${optional}: ${type};`, + ]; +} + +/** + * For a named **string** enum whose values are all valid identifiers, the runtime + * companion `export const X = { a: "a", … } as const;` (cohabiting with the type). + */ +function enumConstLines(named: NamedSchemaModel): string[] { + const schema = named.schema; + if (schema.kind !== 'enum' || schema.scalar !== 'string') return []; + if (!schema.values.every((value) => typeof value === 'string' && isIdentifier(value))) return []; + return [ + `export const ${named.name} = {`, + ...schema.values.map( + (value, index) => + `${INDENT}${value}: ${JSON.stringify(value)}${index === schema.values.length - 1 ? '' : ','}` + ), + '} as const;', + ]; +} + +/** The model type aliases (with JSDoc and enum const companions), blank-line separated. */ +export function renderTypeAliases( + schemas: NamedSchemaModel[], + dateType: DateType = 'string' +): string { + const blocks: string[] = []; + for (const named of schemas) { + const lines = [ + ...tsJsdoc(named.schema.description ?? named.description, named.schema.metadata, ''), + `export type ${named.name} = ${tsType(named.schema, dateType)};`, + ]; + blocks.push(lines.join('\n')); + const constCompanion = enumConstLines(named); + if (constCompanion.length > 0) blocks.push(constCompanion.join('\n')); + } + return blocks.join('\n\n'); +} diff --git a/packages/client-generator/src/emitters/ts.ts b/packages/client-generator/src/emitters/ts.ts deleted file mode 100644 index ce627e4811..0000000000 --- a/packages/client-generator/src/emitters/ts.ts +++ /dev/null @@ -1,191 +0,0 @@ -// Foundation for AST-based code emission: a shared TypeScript printer plus -// `ts.factory` ergonomics. Emitters build `ts.Node`s and print them through -// `printNodes`; hand-authored reference TypeScript is embedded via -// `parseStatements`. The compiler (`ts`) is re-exported so emitters import the -// factory from one place. - -import ts from 'typescript'; - -import { isIdentifier } from './identifier.js'; - -// TypeScript 7 (the native compiler) ships only the tsc binary — none of the compiler -// API everything below is built on — yet its package resolves fine, so the first -// `ts.*` call would die with a bare TypeError. Fail with instructions instead. -if (typeof ts?.createSourceFile !== 'function') { - throw new Error( - `Client generation needs the TypeScript compiler API, but the installed \`typescript\` package` + - `${ts?.version ? ` (${ts.version})` : ''} does not include it — TypeScript 7 ships only the native tsc. ` + - `Install TypeScript 6 for generation (npm i -D typescript@6); your app can still compile the generated client with TypeScript 7.` - ); -} - -export { ts }; - -const printer = ts.createPrinter({ - newLine: ts.NewLineKind.LineFeed, - removeComments: false, -}); - -const blankFile = ts.createSourceFile('', '', ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); - -/** Print a list of nodes to source, tight (one per line) — for import/export groups and single nodes. */ -export function printNodes(nodes: readonly ts.Node[]): string { - return nodes.map(printOne).join('\n'); -} - -/** Print top-level declarations separated by one blank line, for readable declaration bodies. */ -export function printStatements(nodes: readonly ts.Node[]): string { - return nodes.map(printOne).join('\n\n'); -} - -function printOne(node: ts.Node): string { - return printer.printNode(ts.EmitHint.Unspecified, node, sourceFileOf(node)); -} - -// Synthesized (`ts.factory`) nodes have no parent chain — print them against the -// shared blank file. Parsed nodes (from `parseStatements`, built with parent -// nodes set) must print against their own source so literal token text survives. -function sourceFileOf(node: ts.Node): ts.SourceFile { - let current: ts.Node | undefined = node; - while (current) { - if (ts.isSourceFile(current)) return current; - current = current.parent; - } - return blankFile; -} - -/** Parse a source string into its top-level statements (for embedding hand-authored code). */ -export function parseStatements(source: string): ts.Statement[] { - return [ - ...ts.createSourceFile('__embed.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) - .statements, - ]; -} - -/** Parse a single source expression into a ts.Expression (for emitting generator-authored - * expressions like `new Blob([])` that aren't plain data literals). */ -export function parseExpression(source: string): ts.Expression { - const stmt = ts.createSourceFile( - '__expr.ts', - `(${source});`, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS - ).statements[0]; - const parenthesized = (stmt as ts.ExpressionStatement).expression as ts.ParenthesizedExpression; - return parenthesized.expression; -} - -/** - * Attach a block JSDoc leading comment to `node` so it prints as a `/** … *​/` - * block above the node. Multi-line `text` becomes `*`-prefixed lines. - */ -export function jsdoc(node: T, text: string): T { - // Neutralize any embedded `*/` here, at the single choke point every JSDoc block - // flows through: a spec-supplied description/summary/title containing `*/` would - // otherwise close the comment early and turn the rest into live code (injection). - const body = `*\n${escapeJsDoc(text) - .split('\n') - .map((line) => ` * ${line}`.replace(/ +$/, '')) - .join('\n')}\n `; - return ts.addSyntheticLeadingComment(node, ts.SyntaxKind.MultiLineCommentTrivia, body, true); -} - -/** Backslash-escape any comment-closing star-slash so it cannot terminate a block comment. */ -export function escapeJsDoc(text: string): string { - return text.replace(/\*\//g, '*\\/'); -} - -const { factory } = ts; - -/** - * Shared `ts.factory` builders for the handful of node shapes every emitter was - * re-implementing locally (variable statements, arrow functions, `as const` - * arrays). Centralizing them keeps emitters terse and their output identical. - */ - -/** `export const = ;` */ -export function exportConstStatement(name: string, init: ts.Expression): ts.Statement { - return factory.createVariableStatement( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - factory.createVariableDeclarationList( - [factory.createVariableDeclaration(name, undefined, undefined, init)], - ts.NodeFlags.Const - ) - ); -} - -/** An arrow function `() => ` (no explicit return type). */ -export function arrow(params: ts.ParameterDeclaration[], body: ts.ConciseBody): ts.ArrowFunction { - return factory.createArrowFunction( - undefined, - undefined, - params, - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - body - ); -} - -/** An arrow with type parameters, an explicit return type, and a body. */ -export function typedArrow( - typeParameters: ts.TypeParameterDeclaration[], - params: ts.ParameterDeclaration[], - returnType: ts.TypeNode, - body: ts.ConciseBody -): ts.ArrowFunction { - return factory.createArrowFunction( - undefined, - typeParameters, - params, - returnType, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - body - ); -} - -/** `[] as const`. */ -export function constArray(elements: ts.Expression[]): ts.Expression { - return factory.createAsExpression( - factory.createArrayLiteralExpression(elements, false), - factory.createTypeReferenceNode('const') - ); -} - -/** - * A plain JS value as a printable literal expression. Negative numbers print as - * a unary minus over the positive literal (a `NumericLiteral` node cannot carry - * the sign); arrays and objects recurse and print compact, with keys quoted only - * when they fail the identifier GRAMMAR — reserved words (a descriptor's `in` - * field) are legal bare object-literal keys. The primitive overload's narrower - * return type fits `factory.createLiteralTypeNode`. - */ -export function literalExpression( - value: string | number | boolean | null -): ts.LiteralExpression | ts.BooleanLiteral | ts.NullLiteral | ts.PrefixUnaryExpression; -export function literalExpression(value: unknown): ts.Expression; -export function literalExpression(value: unknown): ts.Expression { - if (typeof value === 'string') return factory.createStringLiteral(value); - if (typeof value === 'boolean') return value ? factory.createTrue() : factory.createFalse(); - if (typeof value === 'number') { - return value < 0 - ? factory.createPrefixUnaryExpression( - ts.SyntaxKind.MinusToken, - factory.createNumericLiteral(-value) - ) - : factory.createNumericLiteral(value); - } - if (value === null) return factory.createNull(); - if (Array.isArray(value)) { - return factory.createArrayLiteralExpression(value.map(literalExpression), false); - } - return factory.createObjectLiteralExpression( - Object.entries(value as Record).map(([key, entryValue]) => - factory.createPropertyAssignment( - isIdentifier(key) ? key : factory.createStringLiteral(key), - literalExpression(entryValue) - ) - ), - false - ); -} diff --git a/packages/client-generator/src/emitters/type-guards.ts b/packages/client-generator/src/emitters/type-guards.ts index 8b5f43e47e..747323dad3 100644 --- a/packages/client-generator/src/emitters/type-guards.ts +++ b/packages/client-generator/src/emitters/type-guards.ts @@ -3,52 +3,28 @@ import type { NamedSchemaModel, SchemaModel, } from '../intermediate-representation/model.js'; -import { jsdoc, ts } from './ts.js'; /** * A discriminated union we can emit guards for, found while walking the schema - * tree. `makeParamType` builds the guard's `value` parameter type — the named - * union for a top-level union (`MenuItem`), or the inline member union for one - * nested inside another schema (`SuccessItem | ErrorItem`). `label` is the same, - * rendered for the JSDoc line. A thunk (not a cached node) avoids reusing one - * `ts.TypeNode` across the several guard declarations a site produces. + * tree. `label` is the guard's `value` parameter type — the union's name for a + * top-level union (`MenuItem`), the inline member union (`SuccessItem | ErrorItem`) + * for one nested inside another schema. */ type UnionSite = { union: Extract; label: string; - makeParamType: () => ts.TypeNode; }; -/** - * Emit `is(value): value is ` type guards for every discriminated - * union with a usable discriminator — whether it is a top-level named schema - * (`MenuItem = A | B`) or nested inside one (e.g. the `items` of an array, the - * value of a property). Two discriminator sources: - * - * - Explicit: the union carries a `discriminator` (built from the spec). - * - Implicit: no discriminator, but every member is a ref to a named schema and - * they all constrain one shared property to a distinct string `const`. - * - * Nested unions only qualify when every member is a ref to a named schema, so the - * `value` parameter is a clean union of exported types. Guard names are globally - * deduped (`is`), keeping the first in document order — so a top-level - * union wins its nicer `value: ` parameter over a nested re-occurrence. - * Undiscriminated unions are skipped — TypeScript can't soundly narrow them. - * Returns the guard declarations as nodes (empty when no union narrows). - */ -export function typeGuardStatements(schemas: NamedSchemaModel[]): ts.FunctionDeclaration[] { +/** `is(value): value is ` guards for every discriminated union (explicit or implicit). */ +export function renderTypeGuards(schemas: NamedSchemaModel[]): string { const byName = new Map(schemas.map((s) => [s.name, s.schema] as const)); - const nodes: ts.FunctionDeclaration[] = []; + const blocks: string[] = []; const emitted = new Set(); - for (const named of schemas) { for (const site of collectUnionSites(named)) { const discriminator = site.union.discriminator ?? detectImplicitDiscriminator(site.union, byName); if (!discriminator) continue; - - // Group discriminant values by target schema so two mapping keys pointing at - // the same type produce one guard (a duplicate `is` would not compile). const valuesByTarget = new Map(); for (const entry of discriminator.mapping) { if (!byName.has(entry.schemaName)) continue; @@ -56,29 +32,31 @@ export function typeGuardStatements(schemas: NamedSchemaModel[]): ts.FunctionDec if (existing) existing.push(entry.value); else valuesByTarget.set(entry.schemaName, [entry.value]); } - for (const [schemaName, values] of valuesByTarget) { const guardName = `is${schemaName}`; if (emitted.has(guardName)) continue; emitted.add(guardName); - nodes.push( - buildTypeGuard( - site.makeParamType(), - site.label, - discriminator.propertyName, - schemaName, - values - ) + const access = `(value as Record)[${JSON.stringify(discriminator.propertyName)}]`; + const check = + values.length === 1 + ? `${access} === ${JSON.stringify(values[0])}` + : `([${values.map((value) => JSON.stringify(value)).join(', ')}] as readonly unknown[]).includes(${access})`; + blocks.push( + [ + '/**', + ` * Narrow a \`${site.label}\` to \`${schemaName}\` via its \`${discriminator.propertyName}\` discriminant.`, + ' */', + `export function ${guardName}(value: ${site.label}): value is ${schemaName} {`, + ` return ${check};`, + '}', + ].join('\n') ); } } } - - return nodes; + return blocks.join('\n\n'); } -const { factory } = ts; - /** * The discriminated-union sites reachable from a named schema, in a stable order: * the schema itself (when it is a union), then any nested unions found by walking @@ -89,11 +67,7 @@ function collectUnionSites(named: NamedSchemaModel): UnionSite[] { const sites: UnionSite[] = []; const root = named.schema; if (root.kind === 'union') { - sites.push({ - union: root, - label: named.name, - makeParamType: () => factory.createTypeReferenceNode(named.name), - }); + sites.push({ union: root, label: named.name }); for (const member of root.members) collectNestedSites(member, sites); } else { collectNestedSites(root, sites); @@ -107,12 +81,7 @@ function collectNestedSites(schema: SchemaModel, sites: UnionSite[]): void { case 'union': { const names = schema.members.map((m) => (m.kind === 'ref' ? m.name : undefined)); if (names.every((n): n is string => n !== undefined)) { - sites.push({ - union: schema, - label: names.join(' | '), - makeParamType: () => - factory.createUnionTypeNode(names.map((n) => factory.createTypeReferenceNode(n))), - }); + sites.push({ union: schema, label: names.join(' | ') }); } for (const member of schema.members) collectNestedSites(member, sites); break; @@ -133,75 +102,6 @@ function collectNestedSites(schema: SchemaModel, sites: UnionSite[]): void { } } -/** `(value as Record)[]` — the narrowed property access. */ -function propertyAccess(propertyName: string): ts.Expression { - const recordType = factory.createTypeReferenceNode('Record', [ - factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword), - ]); - return factory.createElementAccessExpression( - factory.createAsExpression(factory.createIdentifier('value'), recordType), - factory.createStringLiteral(propertyName) - ); -} - -function buildTypeGuard( - paramType: ts.TypeNode, - unionLabel: string, - propertyName: string, - schemaName: string, - values: string[] -): ts.FunctionDeclaration { - const access = propertyAccess(propertyName); - const check = - values.length === 1 - ? factory.createBinaryExpression( - access, - factory.createToken(ts.SyntaxKind.EqualsEqualsEqualsToken), - factory.createStringLiteral(values[0]) - ) - : // `([...values] as readonly unknown[]).includes()` - factory.createCallExpression( - factory.createPropertyAccessExpression( - factory.createParenthesizedExpression( - factory.createAsExpression( - factory.createArrayLiteralExpression( - values.map((v) => factory.createStringLiteral(v)) - ), - factory.createTypeOperatorNode( - ts.SyntaxKind.ReadonlyKeyword, - factory.createArrayTypeNode( - factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword) - ) - ) - ) - ), - 'includes' - ), - undefined, - [access] - ); - - const fn = factory.createFunctionDeclaration( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - undefined, - `is${schemaName}`, - undefined, - [factory.createParameterDeclaration(undefined, undefined, 'value', undefined, paramType)], - factory.createTypePredicateNode( - undefined, - 'value', - factory.createTypeReferenceNode(schemaName) - ), - factory.createBlock([factory.createReturnStatement(check)], true) - ); - - return jsdoc( - fn, - `Narrow a \`${unionLabel}\` to \`${schemaName}\` via its \`${propertyName}\` discriminant.` - ); -} - /** * Detect an implicit discriminator: every member is a ref to a named schema, * and they all pin one shared property to a distinct string literal. Returns diff --git a/packages/client-generator/src/emitters/types.ts b/packages/client-generator/src/emitters/types.ts index 43ea994246..30bce7b741 100644 --- a/packages/client-generator/src/emitters/types.ts +++ b/packages/client-generator/src/emitters/types.ts @@ -1,197 +1,5 @@ -import type { - NamedSchemaModel, - PropertyModel, - ScalarKind, - SchemaMetadata, - SchemaModel, -} from '../intermediate-representation/model.js'; -import { isIdentifier, safeIdent } from './identifier.js'; -import { jsdocText } from './jsdoc.js'; -import { jsdoc, literalExpression, printNodes, ts } from './ts.js'; +// 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. -const { factory } = ts; - -/** - * How `format: date-time`/`date` string fields are typed: - * - `'string'` (default): the wire shape — an ISO string. - * - `'Date'`: a `Date` reference. Opt-in; pair with the `transformers` generator - * so the runtime value matches (the client stays zero-dep — `Date` is standard). - */ -export type DateType = 'string' | 'Date'; - -/** The model type aliases (and const-object enum companions) as nodes. */ -export function typesStatements( - schemas: NamedSchemaModel[], - dateType: DateType = 'string' -): ts.Statement[] { - const nodes: ts.Statement[] = []; - for (const s of schemas) { - nodes.push( - jsdocOn( - factory.createTypeAliasDeclaration( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - s.name, - undefined, - schemaToTypeNode(s.schema, dateType) - ), - s.schema.description ?? s.description, - s.schema.metadata - ) - ); - const constObject = enumConstObject(s); - if (constObject) nodes.push(constObject); - } - return nodes; -} - -/** - * For a named **string** enum, build a runtime companion - * `export const X = { a: "a", … } as const;` that cohabits with the same-named - * type (TypeScript allows a type and value to share an identifier). This lets - * callers reference values at runtime (`X.a`) instead of retyping literals. - * - * Returns `undefined` (so only the type union is emitted) when: - * - the schema isn't a string enum (integer/boolean enums gain nothing), or - * - any value isn't a valid JS identifier (e.g. `"menu:read"`) — we don't emit - * a half-usable object with quoted keys. - */ -function enumConstObject(named: NamedSchemaModel): ts.VariableStatement | undefined { - const schema = named.schema; - if (schema.kind !== 'enum' || schema.scalar !== 'string') return undefined; - if (!schema.values.every((v) => typeof v === 'string' && isIdentifier(v))) return undefined; - - const object = factory.createObjectLiteralExpression( - schema.values.map((v) => - factory.createPropertyAssignment(v as string, factory.createStringLiteral(v as string)) - ), - true - ); - return factory.createVariableStatement( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - factory.createVariableDeclarationList( - [ - factory.createVariableDeclaration( - named.name, - undefined, - undefined, - factory.createAsExpression(object, factory.createTypeReferenceNode('const')) - ), - ], - ts.NodeFlags.Const - ) - ); -} - -export function renderSchema(schema: SchemaModel, dateType: DateType = 'string'): string { - return printNodes([schemaToTypeNode(schema, dateType)]); -} - -/** Build the TypeScript type node for an IR schema. */ -export function schemaToTypeNode(schema: SchemaModel, dateType: DateType = 'string'): ts.TypeNode { - switch (schema.kind) { - case 'scalar': - return scalarTypeNode(schema.scalar, schema.metadata, dateType); - case 'ref': - return factory.createTypeReferenceNode(schema.name); - case 'literal': - return factory.createLiteralTypeNode(literalExpression(schema.value)); - case 'enum': { - const members = schema.values.map((v) => factory.createLiteralTypeNode(literalExpression(v))); - // A single-value enum is just that literal — wrapping it in a one-member - // union would make the printer parenthesize it inside `T[]` (`("a")[]`). - return members.length === 1 ? members[0] : factory.createUnionTypeNode(members); - } - case 'null': - return factory.createLiteralTypeNode(factory.createNull()); - case 'unknown': - return factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword); - case 'array': - // The printer parenthesizes union/intersection element types itself - // (`(string | null)[]`), so just hand it the element node. - return factory.createArrayTypeNode(schemaToTypeNode(schema.items, dateType)); - case 'record': - return factory.createTypeReferenceNode('Record', [ - factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - schemaToTypeNode(schema.value, dateType), - ]); - case 'object': - return factory.createTypeLiteralNode( - schema.properties.map((p) => propertySignature(p, dateType)) - ); - case 'union': - return factory.createUnionTypeNode(schema.members.map((m) => schemaToTypeNode(m, dateType))); - case 'intersection': - return factory.createIntersectionTypeNode( - schema.members.map((m) => schemaToTypeNode(m, dateType)) - ); - case 'omit': - return factory.createTypeReferenceNode('Omit', [ - factory.createTypeReferenceNode(schema.base), - factory.createUnionTypeNode( - schema.keys.map((k) => factory.createLiteralTypeNode(factory.createStringLiteral(k))) - ), - ]); - } -} - -function propertySignature(p: PropertyModel, dateType: DateType): ts.PropertySignature { - // `readOnly` (server-managed) props get the `readonly` modifier so consumer - // write-type utilities (OmitReadOnly) can strip them and assignment is - // flagged. Request-body types already drop these via `Omit` in the IR. - const modifiers = p.readOnly - ? [factory.createModifier(ts.SyntaxKind.ReadonlyKeyword)] - : undefined; - const sig = factory.createPropertySignature( - modifiers, - propertyName(p.name), - p.required ? undefined : factory.createToken(ts.SyntaxKind.QuestionToken), - schemaToTypeNode(p.schema, dateType) - ); - return jsdocOn(sig, p.description, p.schema.metadata); -} - -/** A property name: a bare identifier when valid, a quoted string literal otherwise. */ -function propertyName(name: string): ts.PropertyName { - const safe = safeIdent(name); - return safe === name ? factory.createIdentifier(name) : factory.createStringLiteral(name); -} - -function scalarTypeNode( - kind: ScalarKind, - metadata: SchemaMetadata | undefined, - dateType: DateType -): ts.TypeNode { - switch (kind) { - case 'string': - // `format: binary` is raw byte content (file uploads / octet-stream), not text — - // surface it as `Blob` (the web standard; a `File` is assignable to it). `byte` - // (base64) stays a `string`. - if (metadata?.format === 'binary') { - return factory.createTypeReferenceNode('Blob'); - } - // Opt-in: a `date-time`/`date` string surfaces as `Date` under `dateType: - // 'Date'`; everything else (and the default) stays the `string` keyword. - if ( - dateType === 'Date' && - (metadata?.format === 'date-time' || metadata?.format === 'date') - ) { - return factory.createTypeReferenceNode('Date'); - } - return factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); - case 'number': - case 'integer': - return factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword); - case 'boolean': - return factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword); - } -} - -/** Attach a JSDoc block (description + metadata tags) to `node`, if any. */ -function jsdocOn( - node: T, - text: string | undefined, - metadata?: SchemaMetadata -): T { - const body = jsdocText(text, metadata); - return body === undefined ? node : jsdoc(node, body); -} +export type { DateType } from '../authoring/options.js'; diff --git a/packages/client-generator/src/emitters/wrapper-support.ts b/packages/client-generator/src/emitters/wrapper-support.ts index 07971d8dbc..76d5710d02 100644 --- a/packages/client-generator/src/emitters/wrapper-support.ts +++ b/packages/client-generator/src/emitters/wrapper-support.ts @@ -10,9 +10,6 @@ import { logger } from '@redocly/openapi-core'; import type { ApiModel, OperationModel } from '../intermediate-representation/model.js'; import { operationSignature } from './operation-signature.js'; import { isSseOp } from './sse.js'; -import { ts } from './ts.js'; - -const { factory } = ts; /** * The operations a wrapper generator can wrap, with skips reported to the user under @@ -68,102 +65,33 @@ export function variablesName(op: OperationModel): string { return operationSignature(op).variablesTypeName; } -/** A `vars: Variables` parameter. */ -export function varsParam(op: OperationModel): ts.ParameterDeclaration { - return factory.createParameterDeclaration( - undefined, - undefined, - 'vars', - undefined, - factory.createTypeReferenceNode(variablesName(op)) - ); -} - -/** - * An `init?: Omit` parameter. The wrappers cache the - * fetched body, so the throw-only `envelope` option is excluded from the type and - * stripped at runtime by `sdkCall`. - */ -export function initParam(): ts.ParameterDeclaration { - return factory.createParameterDeclaration( - undefined, - undefined, - 'init', - factory.createToken(ts.SyntaxKind.QuestionToken), - factory.createTypeReferenceNode('Omit', [ - factory.createTypeReferenceNode('RequestOptions'), - factory.createLiteralTypeNode(factory.createStringLiteral('envelope')), - ]) - ); -} - -/** - * The forwarding call to the sdk operation function; argument order comes from the - * shared `operationSignature`. `grouped` passes the source object — `{}` for a +/** The forwarding-call ARGUMENT LIST to the sdk operation function, as text. Argument + * order comes from the shared `operationSignature`, so it lines up with the sdk's + * parameter list by construction. `grouped` passes the source object — `{}` for a * no-input op with an init, which must not land in the `(args?, init?)` args slot; - * `flat` spreads `.`, then `.params` / `.body` / `.headers`. - * `withInit` appends `{ ...init, envelope: undefined }` — a runtime strip, since - * `initParam`'s `Omit` is type-only. - */ -export function sdkCall( - op: OperationModel, - argsStyle: 'flat' | 'grouped', - source: string, - withInit: boolean -): ts.Expression { - const sig = operationSignature(op); - const sourceIdent = factory.createIdentifier(source); - const args: ts.Expression[] = []; - - if (argsStyle === 'grouped') { - if (sig.hasInputs) args.push(sourceIdent); - else if (withInit) args.push(factory.createObjectLiteralExpression([])); - } else { - for (const { ident } of sig.pathParams) { - args.push(factory.createPropertyAccessExpression(sourceIdent, ident)); - } - if (sig.hasQuery) args.push(factory.createPropertyAccessExpression(sourceIdent, 'params')); - if (sig.hasBody) args.push(factory.createPropertyAccessExpression(sourceIdent, 'body')); - if (sig.hasHeaders) args.push(factory.createPropertyAccessExpression(sourceIdent, 'headers')); - if (sig.hasCookies) args.push(factory.createPropertyAccessExpression(sourceIdent, 'cookies')); - } - if (withInit) { - args.push( - factory.createObjectLiteralExpression([ - factory.createSpreadAssignment(factory.createIdentifier('init')), - factory.createPropertyAssignment('envelope', factory.createIdentifier('undefined')), - ]) - ); - } - - return factory.createCallExpression(factory.createIdentifier(op.name), undefined, args); + * `flat` spreads `.` (URL-template order), then the slots the op + * has. `withInit` appends `{ ...init, envelope: undefined }` — a runtime strip, since + * the wrappers cache the fetched body and their `Omit`-typed init is type-only. */ +export function sdkCallText(op: OperationModel, source: string, withInit: boolean): string { + const args: string[] = []; + // Every style takes ONE input object, so a wrapper forwards its `Variables` verbatim + // and never has to know which style the sdk was generated with. + if (operationSignature(op).hasInputs) args.push(source); + else if (withInit) args.push('{}'); + if (withInit) args.push('{ ...init, envelope: undefined }'); + return `${op.name}(${args.join(', ')})`; } -/** - * The named import from the sdk module: the wrapped opFns as value specifiers, then - * the referenced `Variables` types + `RequestOptions` (when any query op) as - * `type` specifiers, each group sorted. - */ -export function sdkNamedImport( +/** The named import from the sdk module: wrapped opFns, then the referenced + * `Variables` types + `RequestOptions` (when any query op) as `type` specifiers. */ +export function sdkNamedImportText( ops: OperationModel[], sdkModule: string, hasQuery: boolean -): ts.Statement { +): string { const values = ops.map((op) => op.name).sort(); const types = ops.filter(hasInputs).map(variablesName).sort(); if (hasQuery) types.push('RequestOptions'); - - const specifiers = [ - ...values.map((name) => - factory.createImportSpecifier(false, undefined, factory.createIdentifier(name)) - ), - ...types.map((name) => - factory.createImportSpecifier(true, undefined, factory.createIdentifier(name)) - ), - ]; - return factory.createImportDeclaration( - undefined, - factory.createImportClause(false, undefined, factory.createNamedImports(specifiers)), - factory.createStringLiteral(sdkModule) - ); + const specifiers = [...values, ...types.map((name) => `type ${name}`)].join(', '); + return `import { ${specifiers} } from ${JSON.stringify(sdkModule)};`; } diff --git a/packages/client-generator/src/emitters/zod.ts b/packages/client-generator/src/emitters/zod.ts index 47c8e70b7b..ced0f512e4 100644 --- a/packages/client-generator/src/emitters/zod.ts +++ b/packages/client-generator/src/emitters/zod.ts @@ -1,6 +1,6 @@ // Emits Zod schemas from the IR. Each named schema becomes an -// `export const Schema = z.<…>;` built with `ts.factory`, mirroring the -// type emitter (`types.ts`) but targeting runtime validators instead of types. +// `export const Schema = z.<…>;` — source-text templates mirroring the +// type emitter (`ts-type.ts`) but targeting runtime validators instead of types. // Operations with a JSON request or response body additionally land in the // `operationSchemas` map, which powers the `zodValidation` client middleware. // @@ -12,7 +12,6 @@ import { allOperations, type ApiModel, - type NamedSchemaModel, type PropertyModel, type ScalarKind, type SchemaMetadata, @@ -21,149 +20,109 @@ import { import { safeIdent } from './identifier.js'; import { isSseOp } from './sse.js'; import { pascalCase } from './support.js'; -import { jsdoc, literalExpression, printStatements, ts } from './ts.js'; +import { codeLiteral } from './ts-literal.js'; -const { factory } = ts; +const INDENT = ' '; /** `Schema` — the const identifier a named schema is bound to. */ function schemaConstName(name: string): string { return `${pascalCase(name)}Schema`; } -/** `z` member access: `z.`. */ -function zMember(method: string): ts.Expression { - return factory.createPropertyAccessExpression(factory.createIdentifier('z'), method); -} - -/** `z.(...args)`. */ -function zCall(method: string, args: ts.Expression[] = []): ts.CallExpression { - return factory.createCallExpression(zMember(method), undefined, args); -} - -/** `.(...args)` — chains a refinement onto a base expression. */ -function chain(expr: ts.Expression, method: string, args: ts.Expression[] = []): ts.CallExpression { - return factory.createCallExpression( - factory.createPropertyAccessExpression(expr, method), - undefined, - args - ); -} - type SchemaByName = ReadonlyMap; const NO_SCHEMAS: SchemaByName = new Map(); -/** Map an IR schema to the Zod expression that validates it. */ +/** Map an IR schema to the Zod expression (source text) that validates it. */ export function schemaToZodExpression( schema: SchemaModel, - byName: SchemaByName = NO_SCHEMAS -): ts.Expression { - return withRefinements(baseExpression(schema, byName), schema); + byName: SchemaByName = NO_SCHEMAS, + indent = '' +): string { + return withRefinements(baseExpression(schema, byName, indent), schema); } -function baseExpression(schema: SchemaModel, byName: SchemaByName): ts.Expression { +function baseExpression(schema: SchemaModel, byName: SchemaByName, indent: string): string { switch (schema.kind) { case 'scalar': return scalarExpression(schema.scalar, schema.metadata); case 'object': - return objectExpression(schema.properties, byName); + return objectExpression(schema.properties, byName, indent); case 'array': - return zCall('array', [schemaToZodExpression(schema.items, byName)]); + return `z.array(${schemaToZodExpression(schema.items, byName, indent)})`; case 'record': - return zCall('record', [zCall('string'), schemaToZodExpression(schema.value, byName)]); + return `z.record(z.string(), ${schemaToZodExpression(schema.value, byName, indent)})`; case 'ref': - return lazyRef(schema.name); + return `z.lazy(() => ${schemaConstName(schema.name)})`; case 'literal': - return zCall('literal', [literalExpression(schema.value)]); + return `z.literal(${codeLiteral(schema.value)})`; case 'enum': return enumExpression(schema.values); case 'union': - return unionExpression(schema.members, byName); + return unionExpression(schema.members, byName, indent); case 'intersection': - return intersectionExpression(schema.members, byName); + return schema.members + .map((member) => schemaToZodExpression(member, byName, indent)) + .reduce((acc, next) => `${acc}.and(${next})`); case 'null': - return zCall('null'); + return 'z.null()'; case 'unknown': - return zCall('unknown'); + return 'z.unknown()'; case 'omit': - return omitExpression(schema.base, schema.keys, byName); + return omitExpression(schema.base, schema.keys, byName, indent); } } -function scalarExpression(scalar: ScalarKind, metadata?: SchemaMetadata): ts.Expression { +function scalarExpression(scalar: ScalarKind, metadata?: SchemaMetadata): string { switch (scalar) { case 'string': - // `format: binary` is typed as `Blob` (see types.ts); validate it as one so the zod - // schema agrees with the generated type instead of expecting a string. - if (metadata?.format === 'binary') { - return zCall('instanceof', [factory.createIdentifier('Blob')]); - } - return zCall('string'); + // `format: binary` is typed as `Blob` (see ts-type.ts); validate it as one so the + // zod schema agrees with the generated type instead of expecting a string. + return metadata?.format === 'binary' ? 'z.instanceof(Blob)' : 'z.string()'; case 'integer': - return chain(zCall('number'), 'int'); + return 'z.number().int()'; case 'number': - return zCall('number'); + return 'z.number()'; case 'boolean': - return zCall('boolean'); + return 'z.boolean()'; } } -/** `z.object({ : (.optional() when !required), … })`. */ -function objectExpression(properties: PropertyModel[], byName: SchemaByName): ts.Expression { - const props = properties.map((p) => { - const value = p.required - ? schemaToZodExpression(p.schema, byName) - : chain(schemaToZodExpression(p.schema, byName), 'optional'); - const safe = safeIdent(p.name); - const key = - safe === p.name ? factory.createIdentifier(p.name) : factory.createStringLiteral(p.name); - return factory.createPropertyAssignment(key, value); - }); - return zCall('object', [factory.createObjectLiteralExpression(props, props.length > 0)]); +/** A bare identifier key when valid, a quoted key otherwise. */ +function propertyKeyText(name: string): string { + return safeIdent(name) === name ? name : JSON.stringify(name); } -/** `z.lazy(() => Schema)` — defers reference resolution to call time. */ -function lazyRef(name: string): ts.Expression { - const arrow = factory.createArrowFunction( - undefined, - undefined, - [], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - factory.createIdentifier(schemaConstName(name)) - ); - return zCall('lazy', [arrow]); +/** `z.object({ : (.optional() when !required), … })` — multiline when non-empty. */ +function objectExpression( + properties: PropertyModel[], + byName: SchemaByName, + indent: string +): string { + if (properties.length === 0) return 'z.object({})'; + const inner = indent + INDENT; + const lines = properties.map((property, index) => { + const expr = schemaToZodExpression(property.schema, byName, inner); + const value = property.required ? expr : `${expr}.optional()`; + const comma = index === properties.length - 1 ? '' : ','; + return `${inner}${propertyKeyText(property.name)}: ${value}${comma}`; + }); + return `z.object({\n${lines.join('\n')}\n${indent}})`; } /** All-string values → `z.enum([…])`; otherwise → a union of literals. */ -function enumExpression(values: Array): ts.Expression { - if (values.every((v) => typeof v === 'string')) { - return zCall('enum', [ - factory.createArrayLiteralExpression( - values.map((v) => factory.createStringLiteral(v as string)), - false - ), - ]); +function enumExpression(values: Array): string { + if (values.every((value) => typeof value === 'string')) { + return `z.enum([${values.map((value) => JSON.stringify(value)).join(', ')}])`; } - return zCall('union', [ - factory.createArrayLiteralExpression( - values.map((v) => zCall('literal', [literalExpression(v)])), - false - ), - ]); + return `z.union([${values.map((value) => `z.literal(${codeLiteral(value)})`).join(', ')}])`; } /** `z.union([…])`; a single member collapses to that member's expression. */ -function unionExpression(members: SchemaModel[], byName: SchemaByName): ts.Expression { - const exprs = members.map((member) => schemaToZodExpression(member, byName)); +function unionExpression(members: SchemaModel[], byName: SchemaByName, indent: string): string { + const exprs = members.map((member) => schemaToZodExpression(member, byName, indent)); if (exprs.length === 1) return exprs[0]; - return zCall('union', [factory.createArrayLiteralExpression(exprs, false)]); -} - -/** `a.and(b).and(c)` — left-folds `.and` over the members. */ -function intersectionExpression(members: SchemaModel[], byName: SchemaByName): ts.Expression { - const exprs = members.map((member) => schemaToZodExpression(member, byName)); - return exprs.reduce((acc, next) => chain(acc, 'and', [next])); + return `z.union([${exprs.join(', ')}])`; } /** @@ -171,20 +130,18 @@ function intersectionExpression(members: SchemaModel[], byName: SchemaByName): t * `.omit` exists only on `ZodObject` — for any other base (an `allOf` intersection, * a union, …) the omission is distributed into the base's object members instead. */ -function omitExpression(base: string, keys: string[], byName: SchemaByName): ts.Expression { +function omitExpression( + base: string, + keys: string[], + byName: SchemaByName, + indent: string +): string { const target = byName.get(base); if (target && target.kind !== 'object') { - return schemaToZodExpression(applyOmit(target, keys, byName, new Set([base])), byName); + return schemaToZodExpression(applyOmit(target, keys, byName, new Set([base])), byName, indent); } - const mask = factory.createObjectLiteralExpression( - keys.map((k) => { - const safe = safeIdent(k); - const key = safe === k ? factory.createIdentifier(k) : factory.createStringLiteral(k); - return factory.createPropertyAssignment(key, factory.createTrue()); - }), - false - ); - return chain(factory.createIdentifier(schemaConstName(base)), 'omit', [mask]); + const mask = keys.map((key) => `${propertyKeyText(key)}: true`).join(', '); + return `${schemaConstName(base)}.omit({ ${mask} })`; } /** @@ -231,83 +188,35 @@ function applyOmit( * `.optional()` is NOT applied here — optionality is a property-level concern * handled in `objectExpression`, so a top-level schema is never spuriously optional. */ -function withRefinements(expr: ts.Expression, schema: SchemaModel): ts.Expression { +function withRefinements(expr: string, schema: SchemaModel): string { const m = schema.metadata; if (!m) return expr; let out = expr; if (schema.kind === 'scalar' && schema.scalar === 'string') { - if (m.minLength !== undefined) out = chain(out, 'min', [literalExpression(m.minLength)]); - if (m.maxLength !== undefined) out = chain(out, 'max', [literalExpression(m.maxLength)]); - if (m.pattern !== undefined) out = chain(out, 'regex', [regexExpression(m.pattern)]); + if (m.minLength !== undefined) out = `${out}.min(${m.minLength})`; + if (m.maxLength !== undefined) out = `${out}.max(${m.maxLength})`; + if (m.pattern !== undefined) out = `${out}.regex(new RegExp(${JSON.stringify(m.pattern)}))`; } if (schema.kind === 'scalar' && (schema.scalar === 'number' || schema.scalar === 'integer')) { - out = numericRefinements(out, m); + if (m.minimum !== undefined) out = `${out}.min(${m.minimum})`; + if (m.maximum !== undefined) out = `${out}.max(${m.maximum})`; + if (m.exclusiveMinimum !== undefined) out = `${out}.gt(${m.exclusiveMinimum})`; + if (m.exclusiveMaximum !== undefined) out = `${out}.lt(${m.exclusiveMaximum})`; } if (schema.kind === 'array') { - if (m.minItems !== undefined) out = chain(out, 'min', [literalExpression(m.minItems)]); - if (m.maxItems !== undefined) out = chain(out, 'max', [literalExpression(m.maxItems)]); + if (m.minItems !== undefined) out = `${out}.min(${m.minItems})`; + if (m.maxItems !== undefined) out = `${out}.max(${m.maxItems})`; } return out; } -function numericRefinements(expr: ts.Expression, m: SchemaMetadata): ts.Expression { - let out = expr; - if (m.minimum !== undefined) out = chain(out, 'min', [literalExpression(m.minimum)]); - if (m.maximum !== undefined) out = chain(out, 'max', [literalExpression(m.maximum)]); - if (m.exclusiveMinimum !== undefined) - out = chain(out, 'gt', [literalExpression(m.exclusiveMinimum)]); - if (m.exclusiveMaximum !== undefined) - out = chain(out, 'lt', [literalExpression(m.exclusiveMaximum)]); - return out; -} - -/** `new RegExp("")` — robust across printers regardless of pattern content. */ -function regexExpression(pattern: string): ts.Expression { - return factory.createNewExpression(factory.createIdentifier('RegExp'), undefined, [ - factory.createStringLiteral(pattern), - ]); -} - -/** `export const Schema = ;` for one named schema. */ -function schemaConstStatement(named: NamedSchemaModel, byName: SchemaByName): ts.Statement { - return factory.createVariableStatement( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - factory.createVariableDeclarationList( - [ - factory.createVariableDeclaration( - schemaConstName(named.name), - undefined, - undefined, - schemaToZodExpression(named.schema, byName) - ), - ], - ts.NodeFlags.Const - ) - ); -} - -/** `import { z } from 'zod';` */ -function zodImport(): ts.Statement { - return factory.createImportDeclaration( - undefined, - factory.createImportClause( - false, - undefined, - factory.createNamedImports([ - factory.createImportSpecifier(false, undefined, factory.createIdentifier('z')), - ]) - ), - factory.createStringLiteral('zod') - ); -} - /** * `: { request?: , response?: }` for every non-SSE operation with a * JSON request or response body — the operation's validators, keyed by the same id the * middleware sees at runtime (`ctx.operation.id`). SSE, binary, text, and void bodies * have no JSON payload to validate and are skipped. */ -type OperationSchemaEntry = { name: string; request?: ts.Expression; response?: ts.Expression }; +type OperationSchemaEntry = { name: string; request?: string; response?: string }; function operationSchemaEntries(model: ApiModel, byName: SchemaByName): OperationSchemaEntry[] { const entries: OperationSchemaEntry[] = []; @@ -316,12 +225,14 @@ function operationSchemaEntries(model: ApiModel, byName: SchemaByName): Operatio const requestBody = op.requestBody; const request = requestBody && requestBody.contentType.toLowerCase().includes('json') - ? schemaToZodExpression(requestBody.schema, byName) + ? schemaToZodExpression(requestBody.schema, byName, INDENT) : undefined; const jsonResponse = op.successResponses.find((response) => response.contentType.toLowerCase().includes('json') ); - const response = jsonResponse ? schemaToZodExpression(jsonResponse.schema, byName) : undefined; + const response = jsonResponse + ? schemaToZodExpression(jsonResponse.schema, byName, INDENT) + : undefined; if (!request && !response) continue; // The SPEC operationId — the middleware looks entries up by `ctx.operation.id`, // which stays the spec id even when the emitted function name was renamed. @@ -330,67 +241,34 @@ function operationSchemaEntries(model: ApiModel, byName: SchemaByName): Operatio return entries; } -/** An entry key as a printable property name: bare when a safe identifier, quoted otherwise. */ -function entryKey(name: string): ts.PropertyName { - return safeIdent(name) === name - ? factory.createIdentifier(name) - : factory.createStringLiteral(name); -} - -function operationSchemasStatement(entries: OperationSchemaEntry[]): ts.Statement { - const zodTypeNode = () => - factory.createTypeReferenceNode( - factory.createQualifiedName(factory.createIdentifier('z'), 'ZodType') - ); +function operationSchemasBlock(entries: OperationSchemaEntry[]): string { // The explicit `z.ZodType` annotation keeps the declaration-emit size proportional to // the operation count: the inferred type would serialize every schema's zod generics // and overflow tsc's limit (TS7056) on large APIs under `declaration: true`. - const typeMembers = entries.map((entry) => - factory.createPropertySignature( - undefined, - entryKey(entry.name), - undefined, - factory.createTypeLiteralNode( - [ - entry.request - ? factory.createPropertySignature(undefined, 'request', undefined, zodTypeNode()) - : undefined, - entry.response - ? factory.createPropertySignature(undefined, 'response', undefined, zodTypeNode()) - : undefined, - ].filter((member) => member !== undefined) - ) - ) - ); - const valueEntries = entries.map((entry) => - factory.createPropertyAssignment( - entryKey(entry.name), - factory.createObjectLiteralExpression( - [ - entry.request ? factory.createPropertyAssignment('request', entry.request) : undefined, - entry.response ? factory.createPropertyAssignment('response', entry.response) : undefined, - ].filter((property) => property !== undefined), - false - ) - ) - ); - return jsdoc( - factory.createVariableStatement( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - factory.createVariableDeclarationList( - [ - factory.createVariableDeclaration( - 'operationSchemas', - undefined, - factory.createTypeLiteralNode(typeMembers), - factory.createObjectLiteralExpression(valueEntries, true) - ), - ], - ts.NodeFlags.Const - ) - ), - 'Request/response validators by operationId — powers `zodValidation`, or import one directly.' - ); + const typeLines = entries.flatMap((entry) => [ + `${INDENT}${propertyKeyText(entry.name)}: {`, + ...(entry.request ? [`${INDENT}${INDENT}request: z.ZodType;`] : []), + ...(entry.response ? [`${INDENT}${INDENT}response: z.ZodType;`] : []), + `${INDENT}};`, + ]); + const valueLines = entries.map((entry, index) => { + const fields = [ + ...(entry.request ? [`request: ${entry.request}`] : []), + ...(entry.response ? [`response: ${entry.response}`] : []), + ].join(', '); + const comma = index === entries.length - 1 ? '' : ','; + return `${INDENT}${propertyKeyText(entry.name)}: { ${fields} }${comma}`; + }); + return [ + '/**', + ' * Request/response validators by operationId — powers `zodValidation`, or import one directly.', + ' */', + 'export const operationSchemas: {', + ...typeLines, + '} = {', + ...valueLines, + '};', + ].join('\n'); } // The validation middleware, spliced verbatim after the schemas (matches the printer's @@ -406,11 +284,19 @@ export type ZodViolation = { path: string; message: string; received: string }; /** A request or response payload failed validation. Requests throw it; response handling is configurable. */ export class ZodValidationError extends Error { + // Declared and assigned in the body, NOT as constructor parameter properties: those + // need a transform, so they break \`node --experimental-strip-types\` for anything + // importing this module (the generated CLI runs that way). + readonly operationId: string; + readonly direction: "request" | "response"; + readonly issues: z.ZodError["issues"]; + readonly violations: ZodViolation[]; + constructor( - readonly operationId: string, - readonly direction: "request" | "response", - readonly issues: z.ZodError["issues"], - readonly violations: ZodViolation[] + operationId: string, + direction: "request" | "response", + issues: z.ZodError["issues"], + violations: ZodViolation[] ) { const detail = violations .slice(0, 5) @@ -418,6 +304,10 @@ export class ZodValidationError extends Error { .join("; "); const more = violations.length > 5 ? \`; …and \${violations.length - 5} more\` : ""; super(\`\${direction === "request" ? "Request" : "Response"} validation failed for operation "\${operationId}": \${detail}\${more}\`); + this.operationId = operationId; + this.direction = direction; + this.issues = issues; + this.violations = violations; this.name = "ZodValidationError"; } } @@ -552,11 +442,14 @@ export function renderZodModule(model: ApiModel): string { const byName: SchemaByName = new Map(model.schemas.map((named) => [named.name, named.schema])); const entries = operationSchemaEntries(model, byName); if (model.schemas.length === 0 && entries.length === 0) return ''; - const statements: ts.Statement[] = [ - zodImport(), - ...model.schemas.map((named) => schemaConstStatement(named, byName)), + const blocks = [ + 'import { z } from "zod";', + ...model.schemas.map( + (named) => + `export const ${schemaConstName(named.name)} = ${schemaToZodExpression(named.schema, byName)};` + ), ]; - if (entries.length === 0) return printStatements(statements); - statements.push(operationSchemasStatement(entries)); - return `${printStatements(statements)}\n${VALIDATION_SUPPORT}\n`; + if (entries.length === 0) return blocks.join('\n\n'); + blocks.push(operationSchemasBlock(entries)); + return `${blocks.join('\n\n')}\n${VALIDATION_SUPPORT}\n`; } diff --git a/packages/client-generator/src/generate.ts b/packages/client-generator/src/generate.ts index deb9a66bee..5bcbf3aeb6 100644 --- a/packages/client-generator/src/generate.ts +++ b/packages/client-generator/src/generate.ts @@ -1,39 +1,28 @@ -// The generate entry (`@redocly/client-generator/generate`): everything that runs at -// GENERATION time — `generateClient`, `collectGeneratedFiles`, and the TypeScript-emitting -// toolkit for custom generators. It loads `typescript` and `@redocly/openapi-core`, so it -// must never be reached statically from the package root: package-mode clients import the -// root at app runtime, and the root reaches this module only through the dynamic import -// inside its `generateClient` facade. - -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; +// The generate entry (`@redocly/client-generator/generate`): the TypeScript-emitting +// text toolkit for custom generators plus `collectGeneratedFiles` and a `generateClient` +// re-export. It loads `@redocly/openapi-core` (and, only for `--setup` baking, +// `typescript` — lazily), so it must never be reached statically from the package +// 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 { bakeSetup } from './emitters/setup-bake.js'; -import { NotSupportedError } from './errors.js'; import { builtinGenerators, validateGenerators } from './generators/index.js'; -import { resolveGenerators } from './generators/resolve.js'; import type { GeneratedFile, GeneratorDescriptor, OutputMode } from './generators/types.js'; -import { buildApiModel } from './intermediate-representation/build.js'; import type { ApiModel } from './intermediate-representation/model.js'; -import { normalizeSwagger2 } from './intermediate-representation/normalize-swagger2.js'; -import { loadSpec } from './loader.js'; -import type { GenerateClientOptions, GenerateClientResult } from './types.js'; +import { runGenerators } from './pipeline.js'; // --- Codegen toolkit: build TypeScript the same way the built-in generators do ----------------- -export { - arrow, - constArray, - exportConstStatement, - jsdoc, - parseStatements, - printNodes, - printStatements, - ts, -} from './emitters/ts.js'; +// Source-text templates, not an AST: the `ts.factory`/printer exports were removed +// 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'; +// 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 { schemaToTypeNode } from './emitters/types.js'; export { pascalCase } from './emitters/support.js'; export { safeIdent } from './emitters/identifier.js'; @@ -56,106 +45,11 @@ export function collectGeneratedFiles( const registry = options.registry ?? builtinGenerators(); // Fail fast on an incompatible selection (missing prerequisite, unsupported // error-mode/date-type/runtime) before producing any file. - validateGenerators(options.generators, options.emit, registry); - const files: GeneratedFile[] = []; - const seen = new Set(); - for (const name of options.generators) { - const generator = registry.get(name)!; - for (const file of generator.run({ - model, - outputPath: options.outputPath, - outputMode: options.outputMode, - emit: options.emit, - })) { - if (seen.has(file.path)) { - throw new Error(`Generator conflict: ${file.path} already emitted by an earlier generator`); - } - seen.add(file.path); - files.push(file); - } - } - return files; + validateGenerators(options.generators, options.emit, registry, options.outputMode); + return runGenerators(model, { ...options, registry }); } -export async function generateClient( - options: GenerateClientOptions -): Promise { - // A path segment that is literally "undefined"/"null" is the telltale of an - // interpolation bug in the caller (`\`${dir}/client.ts\`` with `dir` unset) — reject - // it instead of silently creating an `undefined/` directory. - if ( - options.output.split(/[\\/]/).some((segment) => segment === 'undefined' || segment === 'null') - ) { - throw new Error( - `output path "${options.output}" contains a literal "undefined" or "null" segment — this looks like an interpolation bug in the caller` - ); - } - // Setup is a LOCAL module (its code is baked into the generated client) — reject - // URL-ish specifiers upfront, before any spec loading, instead of failing later as - // an unreadable file path. Two+ letter scheme, so Windows drive paths don't match. - if (options.setup && /^[a-z][a-z0-9+.-]+:/i.test(options.setup)) { - throw new NotSupportedError( - `setup must be a local file path — remote setup modules are not supported (got: ${options.setup})` - ); - } - const outputPath = resolve(options.output); - const { document, version } = await loadSpec(options.api, options.config); - const normalized = - version === 'oas2' - ? normalizeSwagger2(document as unknown as Record) - : document; - const model = buildApiModel(normalized); - - // A publisher `--setup` module is read, validated, and transformed into the neutral setup - // expression baked into the client. Applied across all output modes by the emitter. - let setupBlock: string | undefined; - if (options.setup) { - // 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); - setupBlock = bakeSetup(await readFile(setupPath, 'utf-8')); - } - - // Resolve the selection into a registry: built-in names pass through, inline `customGenerators` - // register, and any other entry is imported as a plugin specifier (path/package). - // An empty list (e.g. `generators: []` in config, or no `--generator` flags) means - // "unspecified" — fall back to the default sdk client rather than emitting nothing. - const requested = options.generators?.length ? options.generators : ['sdk']; - const { selected, registry } = await resolveGenerators(requested, { - customGenerators: options.customGenerators, - configDir: options.configDir, - }); - - const files = collectGeneratedFiles(model, { - outputPath, - outputMode: options.outputMode ?? 'single', - emit: { - serverUrl: options.serverUrl, - argsStyle: options.argsStyle, - errorMode: options.errorMode, - dateType: options.dateType, - mockData: options.mockData, - mockSeed: options.mockSeed, - queryKeyPrefix: options.queryKeyPrefix, - setup: setupBlock, - runtime: options.runtime, - importExt: options.importExt, - pagination: options.pagination, - }, - generators: selected, - registry, - }); - - const written: GenerateClientResult['files'] = []; - for (const file of files) { - await mkdir(dirname(file.path), { recursive: true }); - await writeFile(file.path, file.content, 'utf-8'); - written.push({ path: file.path, bytes: Buffer.byteLength(file.content, 'utf-8') }); - } - - return { - outputPath, - bytes: written.reduce((sum, file) => sum + file.bytes, 0), - files: written, - }; -} +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'; diff --git a/packages/client-generator/src/generators/__tests__/cli.test.ts b/packages/client-generator/src/generators/__tests__/cli.test.ts new file mode 100644 index 0000000000..b33eca6fed --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/cli.test.ts @@ -0,0 +1,128 @@ +import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; +import { cliGenerator, cliSample } from '../cli/index.js'; +import { builtinGenerators, validateGenerators } from '../index.js'; + +const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' }; + +const MODEL: ApiModel = { + title: 'Cafe', + version: '1.0.0', + serverUrl: 'https://api.cafe.example', + services: [ + { + name: 'Orders', + operations: [ + { + name: 'getOrder', + specName: 'getOrder', + method: 'get', + path: '/orders/{orderId}', + tags: ['Orders'], + pathParams: [{ name: 'orderId', in: 'path', required: true, schema: STRING }], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + schemas: [ + { + name: 'Order', + schema: { kind: 'object', properties: [{ name: 'id', schema: STRING, required: true }] }, + }, + ], + securitySchemes: [], +} as unknown as ApiModel; + +describe('cliGenerator', () => { + it('emits .cli.ts beside the client, wiring zod only when co-selected', () => { + const files = cliGenerator({ + model: MODEL, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + selected: ['typescript', 'cli'], + }); + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/out/client.cli.ts'); + expect(files[0].content).not.toContain('zodValidation'); + + const withZod = cliGenerator({ + model: MODEL, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + selected: ['typescript', 'zod', 'cli'], + }); + // Request validation always; response validation off for a dry run, whose response is + // the dry-run stub rather than the server's. + expect(withZod[0].content).toContain( + 'use(zodValidation(process.argv.includes("--dry-run") ? { response: false } : {}));' + ); + }); + + it('declares its prerequisites and rejects result mode', () => { + // `typescript` + `zod` are pulled in by the resolver (see resolve.test.ts); validation + // still refuses a selection whose prerequisites are genuinely absent. + expect(builtinGenerators().get('cli')?.requires).toEqual(['typescript', 'zod']); + expect(() => validateGenerators(['cli'], {})).toThrow(/requires the "typescript" generator/); + expect(() => validateGenerators(['typescript', 'zod', 'cli'], { errorMode: 'result' })).toThrow( + /does not support --error-mode "result"/ + ); + expect(() => validateGenerators(['typescript', 'zod', 'cli'], {})).not.toThrow(); + }); + + it('renders a shell x-codeSamples snippet per operation, addressed by the group slug', () => { + const op = MODEL.services[0].operations[0]; + const sample = cliSample(op, { model: MODEL, emit: {}, outputPath: 'client.ts' }); + expect(sample).toMatchObject({ lang: 'shell', label: 'CLI' }); + // The CLI dispatches on the slugged group, so the sample must use it — the raw + // tag ("Orders", or worse a multi-word one) would not resolve. + expect(sample?.source).toContain('orders getOrder '); + expect(sample?.source).not.toContain('Orders getOrder'); + }); + + it('slugs a multi-word tag into the group the CLI accepts', () => { + const model = { + ...MODEL, + services: [ + { + name: 'Orders', + operations: [{ ...MODEL.services[0].operations[0], tags: ['Coffee Orders'] }], + }, + ], + } as ApiModel; + const sample = cliSample(model.services[0].operations[0], { + model, + emit: {}, + outputPath: 'client.ts', + }); + expect(sample?.source).toContain('coffee-orders getOrder '); + }); +}); + +describe('naming', () => { + it('fixes the credential prefix to the stem and takes the command name from argv', () => { + const out = cliGenerator({ + model: MODEL, + outputPath: '/out/openapi.client.ts', + outputMode: 'single', + emit: {}, + })[0].content; + // The prefix is generated, so installing the file under another bin keeps the + // variables; the displayed name follows whatever the operator actually typed, with the + // generated name standing in when `argv[1]` is a script path rather than the command. + expect(out).toContain('envPrefix: "OPENAPI_CLIENT"'); + expect(out).toContain('name: invokedName(process.argv[1], "openapi.client")'); + }); +}); diff --git a/packages/client-generator/src/generators/__tests__/compatibility.test.ts b/packages/client-generator/src/generators/__tests__/compatibility.test.ts new file mode 100644 index 0000000000..9a78b4f206 --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/compatibility.test.ts @@ -0,0 +1,37 @@ +import { GENERATOR_VERSION, satisfiesGeneratorRange } from '../compatibility.js'; + +describe('satisfiesGeneratorRange', () => { + it('reads caret ranges, which are the ones ejected generators carry', () => { + expect(satisfiesGeneratorRange('1.4.2', '^1.2.0')).toBe(true); + expect(satisfiesGeneratorRange('1.2.0', '^1.2.0')).toBe(true); + expect(satisfiesGeneratorRange('1.1.9', '^1.2.0')).toBe(false); + expect(satisfiesGeneratorRange('2.0.0', '^1.2.0')).toBe(false); + // While the package is 0.x the minor is the breaking position, so a caret pins it. + expect(satisfiesGeneratorRange('0.2.9', '^0.2.1')).toBe(true); + expect(satisfiesGeneratorRange('0.3.0', '^0.2.1')).toBe(false); + }); + + it('reads tilde, >=, and exact ranges', () => { + expect(satisfiesGeneratorRange('1.2.9', '~1.2.0')).toBe(true); + expect(satisfiesGeneratorRange('1.3.0', '~1.2.0')).toBe(false); + expect(satisfiesGeneratorRange('9.9.9', '>=1.2.0')).toBe(true); + expect(satisfiesGeneratorRange('1.1.0', '>=1.2.0')).toBe(false); + expect(satisfiesGeneratorRange('1.2.0', '1.2.0')).toBe(true); + expect(satisfiesGeneratorRange('1.2.1', '1.2.0')).toBe(false); + }); + + it('compares numerically, not as strings, and ignores a prerelease suffix', () => { + expect(satisfiesGeneratorRange('1.10.0', '^1.9.0')).toBe(true); + expect(satisfiesGeneratorRange('2.0.0-snapshot.1', '^2.0.0')).toBe(true); + }); + + it('returns undefined for a range it does not read, so the caller can say so', () => { + for (const range of ['1.x || 2', '>1.2.0 <2.0.0', 'latest', '', 'v1']) { + expect(satisfiesGeneratorRange('1.2.0', range)).toBeUndefined(); + } + }); + + it('exposes the running toolkit version', () => { + expect(GENERATOR_VERSION).toMatch(/^\d+\.\d+\.\d+/); + }); +}); 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 1591f760c7..a5009cff29 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 @@ -3,7 +3,7 @@ import type { CustomGenerator } from '../../types.js'; const generator: CustomGenerator = { name: 'route-map', - requires: ['sdk'], + requires: ['typescript'], run({ model, outputPath }) { const routes = model.services .flatMap((s) => s.operations) diff --git a/packages/client-generator/src/generators/__tests__/generator-options.test.ts b/packages/client-generator/src/generators/__tests__/generator-options.test.ts new file mode 100644 index 0000000000..c3aa0e0f07 --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/generator-options.test.ts @@ -0,0 +1,121 @@ +import { logger } from '@redocly/openapi-core'; + +import type { ApiModel } from '../../intermediate-representation/model.js'; +import { runGenerators } from '../../pipeline.js'; +import { resolveGeneratorOptions } from '../options.js'; +import type { GeneratorDescriptor, GeneratorOptionsSchema } from '../types.js'; + +const MATRIX_SCHEMA: GeneratorOptionsSchema = { + type: 'object', + properties: { + groupBy: { enum: ['tag', 'path'], default: 'tag' }, + depth: { type: 'number' }, + include: { type: 'array', items: { type: 'string' } }, + title: { type: 'string' }, + }, + required: ['depth'], + additionalProperties: false, +}; + +function registryWith(descriptor: Partial) { + return new Map([ + ['permissions-matrix', { run: () => [], ...descriptor }], + ]); +} + +describe('resolveGeneratorOptions', () => { + const registry = registryWith({ options: MATRIX_SCHEMA }); + + it('applies declared defaults and passes valid values through', () => { + const resolved = resolveGeneratorOptions(['permissions-matrix'], registry, { + 'permissions-matrix': { depth: 2, include: ['orders'] }, + }); + expect(resolved.get('permissions-matrix')).toEqual({ + groupBy: 'tag', + depth: 2, + include: ['orders'], + }); + }); + + it('rejects an unknown key, a wrong type, a value outside an enum, and a missing required key', () => { + const reject = (options: Record) => () => + resolveGeneratorOptions(['permissions-matrix'], registry, { + 'permissions-matrix': options, + }); + + expect(reject({ depth: 1, groupby: 'tag' })).toThrow( + /"permissions-matrix".*unknown option "groupby".*groupBy, depth, include, title/s + ); + expect(reject({ depth: 'two' })).toThrow(/"depth" must be a number/); + expect(reject({ depth: 1, groupBy: 'paths' })).toThrow(/"groupBy" must be one of: tag, path/); + expect(reject({ depth: 1, include: ['orders', 7] })).toThrow( + /"include" must be an array of string/ + ); + expect(reject({})).toThrow(/requires the "depth" option/); + expect(reject([] as unknown as Record)).toThrow( + /options must be a map of option names to values/ + ); + }); + + it('keeps unknown keys when the schema allows them', () => { + const permissive = registryWith({ + options: { type: 'object', properties: {}, additionalProperties: true }, + }); + const resolved = resolveGeneratorOptions(['permissions-matrix'], permissive, { + 'permissions-matrix': { anything: 'goes' }, + }); + expect(resolved.get('permissions-matrix')).toEqual({ anything: 'goes' }); + }); + + it('warns when a selected generator that declares no options is configured', () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + try { + const resolved = resolveGeneratorOptions(['permissions-matrix'], registryWith({}), { + 'permissions-matrix': { groupBy: 'tag' }, + }); + expect(resolved.get('permissions-matrix')).toEqual({}); + expect(warn.mock.calls.join('\n')).toContain('declares no options'); + } finally { + warn.mockRestore(); + } + }); + + it('ignores options keyed to a generator this run did not select', () => { + expect(() => + resolveGeneratorOptions(['permissions-matrix'], registry, { + 'permissions-matrix': { depth: 1 }, + 'some-other-generator': { whatever: true }, + }) + ).not.toThrow(); + }); +}); + +describe('runGenerators', () => { + it('hands each generator its resolved options', () => { + let seen: unknown; + const registry = new Map([ + [ + 'permissions-matrix', + { + options: MATRIX_SCHEMA, + run: ({ options, outputPath }) => { + seen = options; + return [{ path: outputPath.replace(/\.ts$/, '.permissions.md'), content: '' }]; + }, + }, + ], + ]); + const generatorOptions = resolveGeneratorOptions(['permissions-matrix'], registry, { + 'permissions-matrix': { depth: 3 }, + }); + runGenerators({ title: 'T', version: '1', services: [], schemas: [] } as unknown as ApiModel, { + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + generators: ['permissions-matrix'], + registry, + generatorOptions, + }); + expect(seen).toEqual({ groupBy: 'tag', depth: 3 }); + }); +}); diff --git a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts new file mode 100644 index 0000000000..f763aac2fe --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts @@ -0,0 +1,89 @@ +import { existsSync, readFileSync } from 'node:fs'; +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/). +import { ejectedSkill } from '../../../scripts/ejected-skill.mjs'; + +// Skill-first development: EVERY generator lives in a folder with its own AGENTS.md — +// the design the code must match. A generator folder without a skill, or a skill +// 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]; + +describe.each(EJECTABLE)('%s generator skill', (name) => { + const skillPath = join(generatorsDir, name, 'AGENTS.md'); + + it('exists next to the generator', () => { + expect(existsSync(skillPath)).toBe(true); + }); + + it('states the skill-first rule and how to verify a change', () => { + const skill = readFileSync(skillPath, 'utf-8'); + expect(skill).toContain('edit this skill first'); + expect(skill).toContain('## The modify loop'); + expect(skill).toContain('large-descriptions.test.ts'); + }); +}); + +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', () => { + 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).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'); + + it('has a generator asset and a skill beside it', () => { + expect(existsSync(join(assetsDir, 'generators', `${name}.mjs`))).toBe(true); + const skill = readFileSync(join(assetsDir, 'skills', `${name}-generator`, 'SKILL.md'), 'utf-8'); + expect(skill.startsWith(`---\nname: ${name}-generator\ndescription: `)).toBe(true); + }); + + it('ships the skill fresh — the committed copy is the transform of the source', () => { + // `prepare` rewrites the skill for the user's repo; a hand edit to the shipped copy, + // or a source edit without a prepare run, would ship (and commit) a stale skill. + const shipped = readFileSync( + join(assetsDir, 'skills', `${name}-generator`, 'SKILL.md'), + 'utf-8' + ); + const source = readFileSync(join(generatorsDir, name, 'AGENTS.md'), 'utf-8'); + expect(shipped).toBe(ejectedSkill(source, 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'); + 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 new file mode 100644 index 0000000000..875f57ddd4 --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/go-runtime-embed.test.ts @@ -0,0 +1,35 @@ +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'; + +const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const hasGo = spawnSync('go', ['version']).status === 0; + +// `go build`/`go vet` on a cold CI cache compile the stdlib — well over the 5s default. +vi.setConfig({ testTimeout: 180_000 }); + +describe('GO_RUNTIME_SOURCE (the embedded Go runtime)', () => { + it('embeds the load-bearing declarations', () => { + for (const declaration of [ + 'type APIError struct', + 'type TimeoutError struct', + 'func resolveAuth(', + 'func buildURL(', + 'func send(ctx context.Context', + 'Idempotency-Key', + 'Retry-After', + ]) { + expect(GO_RUNTIME_SOURCE).toContain(declaration); + } + }); + + it.skipIf(!hasGo)('the runtime module passes go vet', () => { + const result = spawnSync('go', ['vet', './...'], { + cwd: join(pkgRoot, 'runtime', 'go'), + 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 new file mode 100644 index 0000000000..2dc7cc59d1 --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/go.test.ts @@ -0,0 +1,597 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +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'; + +const hasGo = spawnSync('go', ['version']).status === 0; + +// Every `expectGoCompiles` bar shells out to `go build`; the first build on a cold +// CI cache compiles the stdlib and takes well over the 5s default. +vi.setConfig({ testTimeout: 180_000 }); + +/** Assert `gofmt` would not change the source — the output must ship idiomatic. */ +function expectGofmtClean(source: string): void { + if (!hasGo) return; + const dir = mkdtempSync(join(tmpdir(), 'go-fmt-')); + try { + const file = join(dir, 'client.go'); + writeFileSync(file, source); + const listed = spawnSync('gofmt', ['-l', file], { encoding: 'utf-8' }); + expect(listed.status, listed.stderr).toBe(0); + const diff = + listed.stdout.trim() === '' + ? '' + : spawnSync('gofmt', ['-d', file], { encoding: 'utf-8' }).stdout; + expect(listed.stdout.trim(), `gofmt would reformat the output:\n${diff}`).toBe(''); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** Assert the rendered source is compilable Go (skipped without the toolchain). */ +function expectGoCompiles(source: string): void { + if (!hasGo) return; + const dir = mkdtempSync(join(tmpdir(), 'go-render-')); + try { + writeFileSync(join(dir, 'go.mod'), 'module render.test\n\ngo 1.21\n'); + writeFileSync(join(dir, 'models.go'), source); + const result = spawnSync('go', ['build', './...'], { cwd: dir, encoding: 'utf-8' }); + expect(result.status, result.stderr).toBe(0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' }; +const INT: SchemaModel = { kind: 'scalar', scalar: 'integer' }; + +function model(schemas: Record): ApiModel { + return { + title: 'Cafe', + version: '1.0.0', + services: [], + schemas: Object.entries(schemas).map(([name, schema]) => ({ name, schema })), + securitySchemes: [], + } as unknown as ApiModel; +} + +describe('renderGoModels', () => { + it('renders structs — required as value fields, optional as pointers with omitempty tags', () => { + const out = renderGoModels( + model({ + Order: { + kind: 'object', + description: 'One placed order.', + properties: [ + { name: 'id', schema: STRING, required: true }, + { name: 'quantity', schema: INT, required: true }, + { name: 'note', schema: STRING, required: false }, + ], + }, + }) + ); + expect(out).toContain('// Order — One placed order.'); + expect(out).toContain('type Order struct {'); + expect(out).toMatch(/Id\s+string\s+`json:"id"`/); + expect(out).toMatch(/Quantity\s+int64\s+`json:"quantity"`/); + expect(out).toMatch(/Note\s+\*string\s+`json:"note,omitempty"`/); + expectGoCompiles(out); + }); + + it('flattens allOf; json tags carry wire names for sanitized fields', () => { + const out = renderGoModels( + model({ + Base: { kind: 'object', properties: [{ name: 'offset', schema: INT, required: false }] }, + Page: { + kind: 'intersection', + members: [ + { kind: 'ref', name: 'Base' }, + { + kind: 'object', + properties: [ + { name: 'items', schema: { kind: 'array', items: STRING }, required: true }, + { name: 'go', schema: STRING, required: true }, // Go keyword as a wire name + ], + }, + ], + }, + }) + ); + expect(out).toContain('type Page struct {'); + expect(out).toMatch(/Items\s+\[\]string\s+`json:"items"`/); + // The exported field name is always usable; the tag keeps the exact wire name. + expect(out).toContain('`json:"go"`'); + expectGoCompiles(out); + }); + + it('renders named enums as typed consts and discriminated unions with an unmarshal dispatcher', () => { + const out = renderGoModels( + model({ + Status: { kind: 'enum', values: ['in-progress', 'done'], scalar: 'string' }, + Cat: { kind: 'object', properties: [] }, + Dog: { kind: 'object', properties: [] }, + Pet: { + kind: 'union', + members: [ + { kind: 'ref', name: 'Cat' }, + { kind: 'ref', name: 'Dog' }, + ], + discriminator: { + propertyName: 'petType', + mapping: [ + { value: 'cat', schemaName: 'Cat' }, + { value: 'dog', schemaName: 'Dog' }, + ], + }, + }, + }) + ); + expect(out).toContain('type Status string'); + expect(out).toContain('StatusInProgress Status = "in-progress"'); + expect(out).toContain('type Pet = any'); + expect(out).toContain('func UnmarshalPet(data []byte) (Pet, error)'); + expect(out).toContain('case "cat":'); + expectGoCompiles(out); + }); + + it('exports digit-leading field names with an N prefix (an _-prefixed field is invisible to encoding/json)', () => { + const out = renderGoModels( + model({ + PaymentMethod: { + kind: 'object', + properties: [{ name: '3ds', schema: STRING, required: false }], + }, + }) + ); + expect(out).toMatch(/N3ds\s+\*string\s+`json:"3ds,omitempty"`/); + expectGoCompiles(out); + }); + + it('keeps +1 and -1 fields distinct and exported (GitHub reactions)', () => { + const out = renderGoModels( + model({ + Reactions: { + kind: 'object', + properties: [ + { name: '+1', schema: INT, required: true }, + { name: '-1', schema: INT, required: true }, + ], + }, + }) + ); + expect(out).toMatch(/Plus1\s+int64\s+`json:"\+1"`/); + expect(out).toMatch(/Minus1\s+int64\s+`json:"-1"`/); + expectGoCompiles(out); + }); + + it('maps nullability and records to pointers and maps', () => { + const out = renderGoModels( + model({ + Thing: { + kind: 'object', + properties: [ + { + name: 'tag', + schema: { kind: 'union', members: [STRING, { kind: 'null' }] }, + required: true, + }, + { name: 'meta', schema: { kind: 'record', value: STRING }, required: true }, + ], + }, + }) + ); + expect(out).toMatch(/Tag\s+\*string\s+`json:"tag"`/); + expect(out).toMatch(/Meta\s+map\[string\]string\s+`json:"meta"`/); + expectGoCompiles(out); + }); +}); + +const CAFE: ApiModel = { + title: 'Cafe', + version: '1.0.0', + serverUrl: 'https://api.cafe.example/organizations/unknown', + servers: [ + { + url: 'https://api.cafe.example/organizations/{organizationId}', + description: 'Live server', + variables: [{ name: 'organizationId', default: 'unknown' }], + }, + { + url: 'https://api-sandbox.cafe.example/organizations/{organizationId}', + description: 'Sandbox server', + variables: [{ name: 'organizationId', default: 'unknown' }], + }, + ], + services: [ + { + name: 'Orders', + operations: [ + { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [ + { name: 'after', in: 'query', required: false, schema: STRING }, + { name: 'limit', in: 'query', required: false, schema: INT }, + ], + headerParams: [], + cookieParams: [], + security: [['BearerAuth']], + paginationExtension: { + style: 'cursor', + cursorParam: 'after', + nextCursor: '/next', + items: '/items', + }, + successResponseHeaders: [ + { + name: 'pagination-total', + schema: { kind: 'scalar', scalar: 'integer' }, + required: true, + }, + { name: 'link', schema: { kind: 'scalar', scalar: 'string' } }, + ], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'OrderPage' }, + }, + ], + errorResponses: [], + }, + { + name: 'streamEvents', + specName: 'streamEvents', + method: 'get', + path: '/events', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'text/event-stream', + schema: { kind: 'object', properties: [] }, + }, + ], + errorResponses: [], + }, + { + name: 'uploadPhoto', + specName: 'uploadPhoto', + method: 'post', + path: '/photos', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + requestBody: { + contentType: 'multipart/form-data', + schema: { kind: 'object', properties: [] }, + }, + successResponses: [{ status: '204', contentType: '', schema: { kind: 'unknown' } }], + errorResponses: [], + }, + { + name: 'getOrder', + specName: 'getOrder', + method: 'get', + path: '/orders/{orderId}', + tags: ['Orders'], + pathParams: [{ name: 'orderId', in: 'path', required: true, schema: STRING }], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + { + name: 'createOrder', + specName: 'createOrder', + method: 'post', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + requestBody: { contentType: 'application/json', schema: { kind: 'ref', name: 'Order' } }, + successResponses: [ + { + status: '201', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + schemas: [ + { + name: 'Order', + schema: { kind: 'object', properties: [{ name: 'id', schema: STRING, required: true }] }, + }, + { + name: 'OrderPage', + schema: { + kind: 'object', + properties: [ + { + name: 'items', + schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, + required: true, + }, + ], + }, + }, + ], + securitySchemes: [{ key: 'BearerAuth', kind: 'bearer' }], +} as unknown as ApiModel; + +function generateGo(): string { + const files = goGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + }); + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/out/client.go'); + return files[0].content; +} + +describe('goGenerator (full client assembly)', () => { + it('renders (T, error) methods over the operations table with typed params structs', () => { + const out = generateGo(); + expect(out).toContain('type Client struct {'); + expect(out).toContain('func New(config Config) *Client {'); + expect(out).toContain('type ListOrdersParams struct {'); + expect(out).toContain('After *string'); + expect(out).toContain( + 'func (c *Client) ListOrders(ctx context.Context, params *ListOrdersParams) (OrderPage, error) {' + ); + expect(out).toContain( + 'func (c *Client) GetOrder(ctx context.Context, orderId string) (Order, error) {' + ); + expect(out).toContain( + 'func (c *Client) CreateOrder(ctx context.Context, body Order) (Order, error) {' + ); + expect(out).toContain('return out, apiErrorFrom(resp, requestURL)'); + }); + + it('assembles one compilable file: models + embedded runtime + operations table', () => { + const out = generateGo(); + expect(out).toContain('var operations = map[string]operationMeta{'); + expect(out).toMatch(/"listOrders":\s+\{/); + expect(out).toContain('func send(ctx context.Context'); // embedded runtime + expect((out.match(/^package client$/gm) ?? []).length).toBe(1); + expectGoCompiles(out); + }); +}); + +describe('goGenerator parity features', () => { + it('paginated operations gain Pages/Items yield-func iterators with typed elements', () => { + const out = generateGo(); + expect(out).toContain('Pagination: &PaginationSpec{Style: "cursor", Param: "after"'); + expect(out).toContain( + 'func (c *Client) ListOrdersPages(ctx context.Context, params *ListOrdersParams) func(yield func(OrderPage, error) bool) {' + ); + expect(out).toContain( + 'func (c *Client) ListOrdersItems(ctx context.Context, params *ListOrdersParams) func(yield func(Order, error) bool) {' + ); + expect(out).toContain('iterPages(call, *op.Pagination, base)'); + }); + + it('SSE operations stream events; multipart bodies route through toMultipart', () => { + const out = generateGo(); + expect(out).toContain( + 'func (c *Client) StreamEvents(ctx context.Context) func(yield func(ServerSentEvent, error) bool) {' + ); + expect(out).toContain('return iterSSE(open,'); + expect(out).toContain('contentType, reader, err := toMultipart(body)'); + expectGoCompiles(out); + }); + + it('collapses consecutive blank lines in a doc comment, as gofmt would', () => { + const out = renderGoModels( + model({ + Documented: { + kind: 'object', + description: 'First paragraph.\n\nSecond paragraph.\n\n\nThird after two blanks.', + properties: [{ name: 'id', schema: STRING, required: true }], + }, + }) + ); + expect(out).toContain('// Documented — First paragraph.'); + expect(out).toContain('// Third after two blanks.'); + // Two empty comment lines in a row is exactly what gofmt rewrites. + expect(out).not.toContain('//\n//\n'); + expectGofmtClean(out); + expectGoCompiles(out); + }); + + it('emits gofmt-clean output — aligned struct fields and const blocks', () => { + const out = generateGo(); + // The alignment gofmt would apply, applied by us. + expect(out).toMatch(/Id\s+string\s+`json:"id"`/); + expectGofmtClean(out); + }); + + it('emits a WithHeaders envelope variant only for ops with declared response headers', () => { + const out = generateGo(); + expect(out).toContain('type ListOrdersHeaders struct {'); + expect(out).toMatch(/PaginationTotal\s+\*int64/); + expect(out).toMatch(/Link\s+\*string/); + expect(out).toContain( + 'func (c *Client) ListOrdersWithHeaders(ctx context.Context, params *ListOrdersParams) (OrderPage, ListOrdersHeaders, error) {' + ); + expect(out).toContain('headers.PaginationTotal = headerInt64(resp.Header, "pagination-total")'); + expect(out).toContain('return out, headers, nil'); + // No declared headers, no variant. + expect(out).not.toContain('GetOrderWithHeaders'); + expectGoCompiles(out); + }); + + it('maps date/date-time to time.Time and Date under dateType: Date', () => { + const DATE_TIME: SchemaModel = { + kind: 'scalar', + scalar: 'string', + metadata: { format: 'date-time' }, + }; + const DATE: SchemaModel = { kind: 'scalar', scalar: 'string', metadata: { format: 'date' } }; + const dated: ApiModel = { + title: 'Cafe', + version: '1.0.0', + serverUrl: 'https://api.cafe.example', + services: [ + { + name: 'Orders', + operations: [ + { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [{ name: 'since', in: 'query', required: false, schema: DATE_TIME }], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + schemas: [ + { + name: 'Order', + schema: { + kind: 'object', + properties: [ + { name: 'placedAt', schema: DATE_TIME, required: true }, + { name: 'dueDate', schema: DATE, required: false }, + { name: 'reminders', schema: { kind: 'array', items: DATE_TIME }, required: false }, + ], + }, + }, + ], + securitySchemes: [], + } as unknown as ApiModel; + + const out = goGenerator({ + model: dated, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { dateType: 'Date' }, + })[0].content; + + expect(out).toMatch(/PlacedAt\s+time\.Time\s+`json:"placedAt"`/); + // A calendar date needs its own type: encoding/json only speaks RFC 3339 for time.Time. + expect(out).toMatch(/DueDate\s+\*Date\s+`json:"dueDate,omitempty"`/); + expect(out).toMatch(/Reminders\s+\[\]time\.Time\s+`json:"reminders,omitempty"`/); + expect(out).toContain('Since *time.Time'); + expect(out).toContain('query.Set("since", (*params.Since).Format(time.RFC3339))'); + expectGoCompiles(out); + + // The default keeps the wire representation. + const asString = goGenerator({ + model: dated, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + })[0].content; + expect(asString).toMatch(/PlacedAt\s+string\s+`json:"placedAt"`/); + }); + + it('models referencing dates compile standalone (the models section imports time)', () => { + const out = renderGoModels( + model({ + Order: { + kind: 'object', + properties: [ + { + name: 'placedAt', + schema: { kind: 'scalar', scalar: 'string', metadata: { format: 'date-time' } }, + required: true, + }, + ], + }, + }), + 'Date' + ); + expect(out).toContain('import "time"'); + expect(out).toContain('PlacedAt time.Time'); + expectGoCompiles(out); + }); + + it('bakes the serverUrl option, not just the description server', () => { + const files = goGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { serverUrl: 'https://override.example' }, + }); + expect(files[0].content).toContain('config.ServerURL = "https://override.example"'); + }); + + it('honors goPackage for the package clause and rejects a name Go would not accept', () => { + const out = goGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { goPackage: 'rebilly' }, + })[0].content; + expect(out).toContain('package rebilly'); + expect(out).not.toContain('package client'); + expectGoCompiles(out); + + expect(() => + goGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { goPackage: 'rebilly-core' }, + }) + ).toThrow(/goPackage "rebilly-core" is not a valid Go package name/); + }); + + it('emits one URL function per declared server with variables as parameters', () => { + const out = generateGo(); + expect(out).toContain('func LiveServerURL(organizationId string) string {'); + expect(out).toContain('func SandboxServerURL(organizationId string) string {'); + expect(out).toContain('return "https://api.cafe.example/organizations/" + organizationId'); + // Go has no default arguments; the spec default lives in the doc comment. + expect(out).toContain('organizationId default: "unknown"'); + expectGoCompiles(out); + }); +}); diff --git a/packages/client-generator/src/generators/__tests__/index.test.ts b/packages/client-generator/src/generators/__tests__/index.test.ts index 6a16b18faf..1f6616cffa 100644 --- a/packages/client-generator/src/generators/__tests__/index.test.ts +++ b/packages/client-generator/src/generators/__tests__/index.test.ts @@ -1,11 +1,13 @@ +import { logger } from '@redocly/openapi-core'; + import { NotSupportedError } from '../../errors.js'; import { builtinGenerators, validateGenerators } from '../index.js'; -import { sdkGenerator } from '../sdk.js'; -import { zodGenerator } from '../zod.js'; +import { typescriptGenerator } from '../typescript/index.js'; +import { zodGenerator } from '../zod/index.js'; describe('builtinGenerators', () => { it('registers the sdk generator descriptor', () => { - expect(builtinGenerators().get('sdk')?.run).toBe(sdkGenerator); + expect(builtinGenerators().get('typescript')?.run).toBe(typescriptGenerator); }); it('registers the zod generator descriptor', () => { @@ -19,7 +21,7 @@ describe('builtinGenerators', () => { describe('validateGenerators', () => { it('accepts sdk alone', () => { - expect(() => validateGenerators(['sdk'], {})).not.toThrow(); + expect(() => validateGenerators(['typescript'], {})).not.toThrow(); }); it('accepts zod alone — it requires nothing', () => { @@ -27,48 +29,110 @@ describe('validateGenerators', () => { }); it('accepts sdk + tanstack-query with the default error-mode', () => { - expect(() => validateGenerators(['sdk', 'tanstack-query'], {})).not.toThrow(); + expect(() => validateGenerators(['typescript', 'tanstack-query'], {})).not.toThrow(); }); it.each(['tanstack-query', 'transformers', 'swr', 'mock'] as const)( - 'rejects %s without sdk, naming the fix', + 'rejects %s without typescript, naming the fix', (generator) => { expect(() => validateGenerators([generator], {})).toThrow( - new RegExp(`requires the "sdk" generator.*--generator sdk --generator ${generator}`) + new RegExp( + `requires the "typescript" generator.*--generator typescript --generator ${generator}` + ) ); } ); it('rejects transformers without --date-type Date (would assign Date to string fields)', () => { - expect(() => validateGenerators(['sdk', 'transformers'], {})).toThrow( + expect(() => validateGenerators(['typescript', 'transformers'], {})).toThrow( /requires --date-type Date .*got "string"/ ); }); it('accepts sdk + transformers with --date-type Date', () => { - expect(() => validateGenerators(['sdk', 'transformers'], { dateType: 'Date' })).not.toThrow(); + expect(() => + validateGenerators(['typescript', 'transformers'], { dateType: 'Date' }) + ).not.toThrow(); }); it.each(['tanstack-query', 'swr'] as const)('rejects %s with result error mode', (generator) => { - expect(() => validateGenerators(['sdk', generator], { errorMode: 'result' })).toThrow( + expect(() => validateGenerators(['typescript', generator], { errorMode: 'result' })).toThrow( /does not support --error-mode "result".*throw/ ); }); + it('rejects --error-mode result for the go and php SDKs (their idiom IS the error mode)', () => { + for (const language of ['go', 'php']) { + expect(() => validateGenerators([language], { errorMode: 'result' })).toThrow( + /does not support --error-mode "result"/ + ); + // Throw mode — what they actually emit — stays valid. + expect(() => validateGenerators([language], { errorMode: 'throw' })).not.toThrow(); + } + // python implements both modes. + expect(() => validateGenerators(['python'], { errorMode: 'result' })).not.toThrow(); + }); + + it('warns (never silently drops) when a language SDK ignores an option the user set', () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + try { + // `outputMode` travels beside `emit`, hence the trailing argument. + validateGenerators(['php'], { runtime: 'package', 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'); + expect(messages).toContain('the "php" generator ignores argsStyle'); + + // Defaults must stay quiet: only an EXPLICIT option warns. + warn.mockClear(); + validateGenerators(['php'], {}); + expect(warn).not.toHaveBeenCalled(); + + // The TypeScript sdk applies all of them — no warning. + warn.mockClear(); + validateGenerators( + ['typescript'], + { runtime: 'package', argsStyle: 'grouped' }, + undefined, + 'split' + ); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it('warns when a single-generator option is set without its generator', () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + try { + validateGenerators(['python'], { goPackage: 'mypkg' }); + const messages = warn.mock.calls.map(([message]) => message).join(''); + expect(messages).toContain('goPackage is ignored'); + + // The generator that reads it is selected, so nothing to say — even alongside + // generators that don't read it. + warn.mockClear(); + validateGenerators(['typescript', 'go'], { goPackage: 'mypkg' }); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + it('throws NotSupportedError for an unknown generator name', () => { expect(() => validateGenerators(['nope' as never], {})).toThrow(NotSupportedError); }); }); describe('swr generator', () => { - it('is registered and requires sdk', () => { + it('is registered and requires typescript', () => { const descriptor = builtinGenerators().get('swr'); expect(descriptor?.run).toBeDefined(); - expect(descriptor?.requires).toContain('sdk'); + expect(descriptor?.requires).toContain('typescript'); }); it('accepts sdk + swr with the default error-mode', () => { - expect(() => validateGenerators(['sdk', 'swr'], {})).not.toThrow(); + expect(() => validateGenerators(['typescript', 'swr'], {})).not.toThrow(); }); }); @@ -96,7 +160,7 @@ describe('validateGenerators — runtime compatibility', () => { it('accepts the wrapper generators with runtime: package (no longer restricted)', () => { expect(() => validateGenerators( - ['sdk', 'tanstack-query', 'swr'], + ['typescript', 'tanstack-query', 'swr'], { runtime: 'package' }, builtinGenerators() ) @@ -105,11 +169,11 @@ describe('validateGenerators — runtime compatibility', () => { }); describe('mock generator', () => { - it('is registered and requires sdk', () => { - expect(builtinGenerators().get('mock')?.requires).toContain('sdk'); + it('is registered and requires typescript', () => { + expect(builtinGenerators().get('mock')?.requires).toContain('typescript'); }); it('validateGenerators accepts sdk + mock', () => { - expect(() => validateGenerators(['sdk', 'mock'], {})).not.toThrow(); + expect(() => validateGenerators(['typescript', 'mock'], {})).not.toThrow(); }); }); diff --git a/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts b/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts new file mode 100644 index 0000000000..0912c5d00b --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } 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 +]); + +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 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([]); + }); + } +); diff --git a/packages/client-generator/src/generators/__tests__/mock.test.ts b/packages/client-generator/src/generators/__tests__/mock.test.ts index c578eca04e..53a00049ff 100644 --- a/packages/client-generator/src/generators/__tests__/mock.test.ts +++ b/packages/client-generator/src/generators/__tests__/mock.test.ts @@ -1,5 +1,5 @@ import { apiModel, namedSchema, operation, response } from '../../emitters/__tests__/fixtures.js'; -import { mockGenerator } from '../mock.js'; +import { mockGenerator } from '../mock/index.js'; 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 new file mode 100644 index 0000000000..87a5d5cd51 --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/php-runtime-embed.test.ts @@ -0,0 +1,35 @@ +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'; + +const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const hasPhp = spawnSync('php', ['--version']).status === 0; + +describe('PHP_RUNTIME_SOURCE (the embedded PHP runtime)', () => { + it('embeds the load-bearing declarations', () => { + for (const declaration of [ + 'final class ApiError extends \\RuntimeException', + 'final class TimeoutError extends \\RuntimeException', + 'function resolveAuth(', + 'function buildUrl(', + 'function send(Config $config', + 'function iterPages(', + 'function iterSse(', + 'function toMultipart(', + 'Idempotency-Key', + 'retry-after', + ]) { + expect(PHP_RUNTIME_SOURCE).toContain(declaration); + } + }); + + it.skipIf(!hasPhp)('the runtime module passes php -l', () => { + const result = spawnSync('php', ['-l', 'runtime.php'], { + cwd: join(pkgRoot, 'runtime', 'php'), + 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 new file mode 100644 index 0000000000..30963fd5af --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/php.test.ts @@ -0,0 +1,711 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +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'; + +const hasPhp = spawnSync('php', ['--version']).status === 0; + +/** Assert the rendered source parses AND declares cleanly (php -l, then require). */ +function expectPhpRuns(source: string): void { + if (!hasPhp) return; + const dir = mkdtempSync(join(tmpdir(), 'php-render-')); + try { + writeFileSync(join(dir, 'client.php'), source); + const lint = spawnSync('php', ['-l', 'client.php'], { cwd: dir, encoding: 'utf-8' }); + expect(lint.status, `${lint.stdout}\n${lint.stderr}`).toBe(0); + const declare = spawnSync('php', ['-r', "require 'client.php'; echo 'DECLARED';"], { + cwd: dir, + encoding: 'utf-8', + }); + expect(declare.status, `${declare.stdout}\n${declare.stderr}`).toBe(0); + expect(declare.stdout).toContain('DECLARED'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** Models-only sources still need the file header to parse standalone. */ +function expectModelsRun(models: string): void { + expectPhpRuns(`): ApiModel { + return { + title: 'Cafe', + version: '1.0.0', + services: [], + schemas: Object.entries(schemas).map(([name, schema]) => ({ name, schema })), + securitySchemes: [], + } as unknown as ApiModel; +} + +describe('phpType — unions', () => { + const ENUM: SchemaModel = { kind: 'enum', values: ['a', 'b'], scalar: 'string' }; + const base = model({ + Kind: ENUM, + Order: { kind: 'object', properties: [] }, + }); + + it('renders a union of expressible members as a native PHP 8.1 union', () => { + expect(phpType({ kind: 'union', members: [STRING, INT] }, base)).toBe('string|int'); + expect( + phpType( + { + kind: 'union', + members: [ + { kind: 'ref', name: 'Kind' }, + { kind: 'array', items: STRING }, + ], + }, + base + ) + ).toBe('Kind|array'); + // A class member keeps its class name. + expect( + phpType({ kind: 'union', members: [{ kind: 'ref', name: 'Order' }, STRING] }, base) + ).toBe('Order|string'); + }); + + it('expresses nullability as |null inside a union — PHP forbids mixing ? with |', () => { + const type = phpType({ kind: 'union', members: [STRING, INT, { kind: 'null' }] }, base); + expect(type).toBe('string|int|null'); + expect(type.startsWith('?')).toBe(false); + // A single nullable type keeps the shorthand. + expect(phpType({ kind: 'union', members: [STRING, { kind: 'null' }] }, base)).toBe('?string'); + }); + + it('makes an OPTIONAL union nullable with |null, never a leading ?', () => { + const out = renderPhpModels( + model({ + Cash: { kind: 'object', properties: [] }, + Card: { kind: 'object', properties: [] }, + Customer: { + kind: 'object', + properties: [ + { + name: 'instrument', + schema: { + kind: 'union', + members: [ + { kind: 'ref', name: 'Cash' }, + { kind: 'ref', name: 'Card' }, + ], + }, + required: false, + }, + ], + }, + }) + ); + expect(out).toContain('public Cash|Card|null $instrument = null'); + // `?Cash|Card` is a parse error. + expect(out).not.toContain('?Cash|Card'); + expectModelsRun(out); + }); + + it('falls back to mixed when a member has no PHP type — mixed cannot be a union member', () => { + const withInlineObject: SchemaModel = { + kind: 'union', + members: [STRING, { kind: 'object', properties: [] }], + }; + expect(phpType(withInlineObject, base)).toBe('mixed'); + expect(phpType({ kind: 'union', members: [STRING, { kind: 'unknown' }] }, base)).toBe('mixed'); + }); + + it('deduplicates members that map to the same PHP type', () => { + expect(phpType({ kind: 'union', members: [STRING, ENUM] }, base)).toBe('string'); + }); +}); + +describe('renderPhpModels', () => { + it('renders classes — required first, optionals nullable with defaults, wire maps preserved', () => { + const out = renderPhpModels( + model({ + Order: { + kind: 'object', + description: 'One placed order.', + properties: [ + { name: 'id', schema: STRING, required: true }, + { name: 'quantity', schema: INT, required: true }, + { name: 'special-note', schema: STRING, required: false }, + ], + }, + }) + ); + expect(out).toContain('final class Order'); + expect(out).toContain('public string $id'); + expect(out).toContain('public int $quantity'); + expect(out).toContain('public ?string $specialNote = null'); + expect(out).toContain("$data['special-note']"); // wire name survives in the field map + expect(out).toContain('public static function fromArray(array $data): self'); + expect(out).toContain('public function toArray(): array'); + expectModelsRun(out); + }); + + it('flattens allOf and hydrates nested refs, arrays of refs, and enums', () => { + const out = renderPhpModels( + model({ + Base: { kind: 'object', properties: [{ name: 'offset', schema: INT, required: false }] }, + Status: { kind: 'enum', values: ['in-progress', 'done'], scalar: 'string' }, + Order: { + kind: 'object', + properties: [{ name: 'status', schema: { kind: 'ref', name: 'Status' }, required: true }], + }, + Page: { + kind: 'intersection', + members: [ + { kind: 'ref', name: 'Base' }, + { + kind: 'object', + properties: [ + { + name: 'items', + schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, + required: true, + }, + ], + }, + ], + }, + }) + ); + expect(out).toContain('final class Page'); + expect(out).toContain('enum Status: string'); + expect(out).toContain("case InProgress = 'in-progress';"); + expect(out).toContain("Status::from($data['status'])"); + expect(out).toContain( + "array_map(static fn ($item) => Order::fromArray($item), $data['items'])" + ); + expectModelsRun(out); + }); + + it('renders discriminated unions as match dispatchers and keeps +1/-1 distinct', () => { + const out = renderPhpModels( + model({ + Cat: { kind: 'object', properties: [] }, + Dog: { kind: 'object', properties: [] }, + Pet: { + kind: 'union', + members: [ + { kind: 'ref', name: 'Cat' }, + { kind: 'ref', name: 'Dog' }, + ], + discriminator: { + propertyName: 'petType', + mapping: [ + { value: 'cat', schemaName: 'Cat' }, + { value: 'dog', schemaName: 'Dog' }, + ], + }, + }, + Reactions: { + kind: 'object', + properties: [ + { name: '+1', schema: INT, required: true }, + { name: '-1', schema: INT, required: true }, + ], + }, + }) + ); + expect(out).toContain('function unmarshalPet(array $data): mixed'); + expect(out).toContain("'cat' => Cat::fromArray($data)"); + expect(out).toContain('public int $plus1'); + expect(out).toContain('public int $minus1'); + expectModelsRun(out); + }); + + it('hydrates discriminated-union properties through the dispatcher so instanceof works', () => { + const out = renderPhpModels( + model({ + Cat: { kind: 'object', properties: [] }, + Dog: { kind: 'object', properties: [] }, + Pet: { + kind: 'union', + members: [ + { kind: 'ref', name: 'Cat' }, + { kind: 'ref', name: 'Dog' }, + ], + discriminator: { + propertyName: 'petType', + mapping: [ + { value: 'cat', schemaName: 'Cat' }, + { value: 'dog', schemaName: 'Dog' }, + ], + }, + }, + Owner: { + kind: 'object', + properties: [ + { name: 'pet', schema: { kind: 'ref', name: 'Pet' }, required: true }, + { + name: 'pets', + schema: { kind: 'array', items: { kind: 'ref', name: 'Pet' } }, + required: false, + }, + ], + }, + }) + ); + expect(out).toContain("pet: unmarshalPet($data['pet'])"); + expect(out).toContain('array_map(static fn ($item) => unmarshalPet($item)'); + // Serialization must accept both hydrated instances and raw arrays. + expect(out).toContain('is_object($this->pet) ? $this->pet->toArray() : $this->pet'); + expectModelsRun(out); + }); + + it('maps nullability and reserved names idiomatically', () => { + const out = renderPhpModels( + model({ + Lesson: { + kind: 'object', + properties: [ + { + name: 'tag', + schema: { kind: 'union', members: [STRING, { kind: 'null' }] }, + required: true, + }, + { name: 'class', schema: STRING, required: true }, + ], + }, + }) + ); + expect(out).toContain('public ?string $tag'); + expect(out).toContain('public string $class_'); + expect(out).toContain("$data['class']"); + expectModelsRun(out); + }); +}); + +const CAFE: ApiModel = { + title: 'Cafe Orders API', + version: '1.0.0', + serverUrl: 'https://api.cafe.example/organizations/unknown', + servers: [ + { + url: 'https://api.cafe.example/organizations/{organizationId}', + description: 'Live server', + variables: [{ name: 'organizationId', default: 'unknown' }], + }, + { + url: 'https://api-sandbox.cafe.example/organizations/{organizationId}', + description: 'Sandbox server', + variables: [{ name: 'organizationId', default: 'unknown' }], + }, + ], + services: [ + { + name: 'Orders', + operations: [ + { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [ + { name: 'after', in: 'query', required: false, schema: STRING }, + { name: 'limit', in: 'query', required: false, schema: INT }, + ], + headerParams: [], + cookieParams: [], + security: [['BearerAuth']], + paginationExtension: { + style: 'cursor', + cursorParam: 'after', + nextCursor: '/next', + items: '/items', + }, + successResponseHeaders: [ + { + name: 'pagination-total', + schema: { kind: 'scalar', scalar: 'integer' }, + required: true, + }, + { name: 'link', schema: { kind: 'scalar', scalar: 'string' } }, + ], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'OrderPage' }, + }, + ], + errorResponses: [], + }, + { + name: 'getOrder', + specName: 'getOrder', + method: 'get', + path: '/orders/{orderId}', + tags: ['Orders'], + pathParams: [{ name: 'orderId', in: 'path', required: true, schema: STRING }], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + { + name: 'getOrderPdf', + specName: 'getOrderPdf', + method: 'get', + path: '/orders/{orderId}/pdf', + tags: ['Orders'], + pathParams: [{ name: 'orderId', in: 'path', required: true, schema: STRING }], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/pdf', + schema: { kind: 'scalar', scalar: 'string', metadata: { format: 'binary' } }, + }, + ], + errorResponses: [], + }, + { + name: 'createOrder', + specName: 'createOrder', + method: 'post', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + requestBody: { + contentType: 'application/json', + required: true, + schema: { kind: 'ref', name: 'Order' }, + }, + successResponses: [ + { + status: '201', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + { + name: 'streamEvents', + specName: 'streamEvents', + method: 'get', + path: '/events', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'text/event-stream', + schema: { kind: 'object', properties: [] }, + }, + ], + errorResponses: [], + }, + { + name: 'uploadPhoto', + specName: 'uploadPhoto', + method: 'post', + path: '/photos', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + requestBody: { + contentType: 'multipart/form-data', + required: true, + schema: { kind: 'object', properties: [] }, + }, + successResponses: [{ status: '204', contentType: '', schema: { kind: 'unknown' } }], + errorResponses: [], + }, + ], + }, + ], + schemas: [ + { + name: 'Order', + schema: { kind: 'object', properties: [{ name: 'id', schema: STRING, required: true }] }, + }, + { + name: 'OrderPage', + schema: { + kind: 'object', + properties: [ + { + name: 'items', + schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, + required: true, + }, + { name: 'next', schema: STRING, required: false }, + ], + }, + }, + ], + securitySchemes: [{ key: 'BearerAuth', kind: 'bearer' }], +} as unknown as ApiModel; + +function generatePhp(): string { + const files = phpGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + }); + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/out/client.php'); + return files[0].content; +} + +describe('phpGenerator (full client assembly)', () => { + it('assembles one runnable file: namespace, models, embedded runtime, operations, Client', () => { + const out = generatePhp(); + expect(out.startsWith(' { + const out = generatePhp(); + expect(out).toContain('public function listOrdersPages('); + expect(out).toContain('public function listOrdersItems('); + expect(out).toContain('iterPages($call,'); + expect(out).toContain('yield OrderPage::fromArray($page);'); + expect(out).toContain('public function streamEvents(?array $headers = null): \\Generator'); + expect(out).toContain('yield from iterSse($open,'); + expect(out).toContain('toMultipart($body)'); + expectPhpRuns(out); + }); + + it('returns the raw body string for non-JSON success responses (PDF download)', () => { + const out = generatePhp(); + expect(out).toContain( + 'public function getOrderPdf(string $orderId, ?array $headers = null): string' + ); + expect(out).toContain("return $response['body'];"); + }); + + it('documents element types PHP cannot express in the signature', () => { + // A bare-array collection: `array` in the signature, element type in the docblock. + const collection: ApiModel = { + title: 'Cafe', + version: '1.0.0', + serverUrl: 'https://api.cafe.example', + services: [ + { + name: 'Orders', + operations: [ + { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [{ name: 'cursor', in: 'query', required: false, schema: STRING }], + headerParams: [], + cookieParams: [], + security: [], + paginationExtension: { style: 'cursor', cursorParam: 'cursor', items: '' }, + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + schemas: [ + { + name: 'Order', + schema: { kind: 'object', properties: [{ name: 'id', schema: STRING, required: true }] }, + }, + ], + securitySchemes: [], + } as unknown as ApiModel; + + const out = phpGenerator({ + model: collection, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + })[0].content; + + expect(out).toContain('@return Order[]'); + // Iterators say what they yield, so static analysis can follow them. + expect(out).toContain('@return \\Generator'); + expect(out).toContain('@return \\Generator'); + expectPhpRuns(out); + }); + + it('emits a WithHeaders envelope variant only for ops with declared response headers', () => { + const out = generatePhp(); + expect(out).toContain('public function listOrdersWithHeaders('); + expect(out).toContain( + "readEnvelopeHeaders($response, [['pagination-total', 'paginationTotal', 'integer'], ['link', 'link', 'string']])" + ); + expect(out).toContain("status: $response['status']"); + // No declared headers, no variant. + expect(out).not.toContain('getOrderWithHeaders'); + expectPhpRuns(out); + }); + + it('puts the brace on the line after a declaration, with no blank line between', () => { + const out = renderPhpModels( + model({ + Status: { kind: 'enum', values: ['open'], scalar: 'string' }, + Order: { kind: 'object', properties: [{ name: 'id', schema: STRING, required: true }] }, + }) + ); + expect(out).toContain('final class Order\n{'); + expect(out).toContain('enum Status: string\n{'); + expect(out).toContain('public static function fromArray(array $data): self\n {'); + expect(out).not.toMatch(/\n\n\s*\{/); + expectModelsRun(out); + }); + + it('maps date/date-time to DateTimeImmutable under dateType: Date, hydrating both ways', () => { + const DATE_TIME: SchemaModel = { + kind: 'scalar', + scalar: 'string', + metadata: { format: 'date-time' }, + }; + const DATE: SchemaModel = { kind: 'scalar', scalar: 'string', metadata: { format: 'date' } }; + const dated: ApiModel = { + title: 'Cafe', + version: '1.0.0', + serverUrl: 'https://api.cafe.example', + services: [ + { + name: 'Orders', + operations: [ + { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [{ name: 'since', in: 'query', required: false, schema: DATE_TIME }], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + schemas: [ + { + name: 'Order', + schema: { + kind: 'object', + properties: [ + { name: 'placedAt', schema: DATE_TIME, required: true }, + { name: 'dueDate', schema: DATE, required: false }, + { name: 'reminders', schema: { kind: 'array', items: DATE_TIME }, required: false }, + ], + }, + }, + ], + securitySchemes: [], + } as unknown as ApiModel; + + const out = phpGenerator({ + model: dated, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { dateType: 'Date' }, + })[0].content; + + expect(out).toContain('public \\DateTimeImmutable $placedAt'); + expect(out).toContain('public ?\\DateTimeImmutable $dueDate = null'); + // Hydration and serialization both convert, including inside arrays. + expect(out).toContain("new \\DateTimeImmutable($data['placedAt'])"); + expect(out).toContain( + "array_map(static fn ($item) => new \\DateTimeImmutable($item), $data['reminders'])" + ); + expect(out).toContain('$this->placedAt->format(\\DateTimeInterface::ATOM)'); + expect(out).toContain("$this->dueDate->format('Y-m-d')"); + expect(out).toContain('?\\DateTimeImmutable $since = null'); + expectPhpRuns(out); + + // The default keeps the wire representation. + const asString = phpGenerator({ + model: dated, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + })[0].content; + expect(asString).toContain('public string $placedAt'); + }); + + it('bakes the serverUrl option, not just the description server', () => { + const files = phpGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { serverUrl: 'https://override.example' }, + }); + expect(files[0].content).toContain("$this->config->serverUrl = 'https://override.example';"); + }); + + it('emits a Servers class with named variable arguments defaulting to the spec defaults', () => { + const out = generatePhp(); + expect(out).toContain('final class Servers'); + expect(out).toContain( + "public static function liveServer(string $organizationId = 'unknown'): string" + ); + expect(out).toContain( + "public static function sandboxServer(string $organizationId = 'unknown'): string" + ); + expect(out).toContain("return 'https://api.cafe.example/organizations/' . $organizationId;"); + expectPhpRuns(out); + }); +}); 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 new file mode 100644 index 0000000000..9dbd145175 --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/python-runtime-embed.test.ts @@ -0,0 +1,32 @@ +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'; + +const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const hasPython = spawnSync('python3', ['--version']).status === 0; + +describe('PYTHON_RUNTIME_SOURCES (the embedded Python runtime)', () => { + it('embeds every runtime module with its load-bearing declarations', () => { + expect(PYTHON_RUNTIME_SOURCES['_errors.py']).toContain('class ApiError'); + expect(PYTHON_RUNTIME_SOURCES['_errors.py']).toContain('class ApiTimeoutError'); + expect(PYTHON_RUNTIME_SOURCES['_errors.py']).toContain('class Result'); + expect(PYTHON_RUNTIME_SOURCES['_auth.py']).toContain('def resolve_auth'); + expect(PYTHON_RUNTIME_SOURCES['_send.py']).toContain('def send'); + expect(PYTHON_RUNTIME_SOURCES['_send.py']).toContain('Idempotency-Key'); + }); + + it.skipIf(!hasPython)('the runtime sources are valid Python (py_compile)', () => { + for (const name of Object.keys(PYTHON_RUNTIME_SOURCES)) { + const result = spawnSync( + 'python3', + ['-m', 'py_compile', join(pkgRoot, 'runtime', 'python', name)], + { + encoding: 'utf-8', + } + ); + expect(result.status, `${name}: ${result.stderr}`).toBe(0); + } + }); +}); diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts new file mode 100644 index 0000000000..8f74b9036b --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/python.test.ts @@ -0,0 +1,796 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +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'; + +const hasPython = spawnSync('python3', ['--version']).status === 0; +const hasHttpx = hasPython && spawnSync('python3', ['-c', 'import httpx']).status === 0; + +/** Assert the rendered source is valid Python (skipped when python3 is absent). */ +function expectCompiles(source: string): void { + if (!hasPython) return; + const dir = mkdtempSync(join(tmpdir(), 'py-render-')); + try { + const file = join(dir, 'models.py'); + writeFileSync(file, source); + const result = spawnSync('python3', ['-m', 'py_compile', file], { encoding: 'utf-8' }); + expect(result.status, result.stderr).toBe(0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' }; +const INT: SchemaModel = { kind: 'scalar', scalar: 'integer' }; + +function model(schemas: Record): ApiModel { + return { + title: 'Cafe', + version: '1.0.0', + services: [], + schemas: Object.entries(schemas).map(([name, schema]) => ({ name, schema })), + securitySchemes: [], + } as unknown as ApiModel; +} + +describe('renderPythonModels', () => { + it('renders an object schema as a dataclass — required fields first, optional with = None', () => { + const out = renderPythonModels( + model({ + Order: { + kind: 'object', + description: 'One placed order.', + properties: [ + { name: 'note', schema: STRING, required: false }, + { name: 'id', schema: STRING, required: true }, + { name: 'quantity', schema: INT, required: true }, + ], + }, + }) + ); + expect(out).toContain('from __future__ import annotations'); + expect(out).toContain('@dataclass\nclass Order:'); + expect(out).toContain('"""One placed order."""'); + // Required (no default) precede optional (= None) — a Python dataclass constraint. + const id = out.indexOf('id: str'); + const note = out.indexOf('note: Optional[str] = None'); + expect(id).toBeGreaterThan(-1); + expect(note).toBeGreaterThan(id); + }); + + it("renders pydantic models under models: 'pydantic', with wire names as aliases", () => { + const out = renderPythonModels( + model({ + Order: { + kind: 'object', + properties: [ + { name: 'id', schema: STRING, required: true }, + // A wire name that is not a legal Python field name: the alias carries it. + { name: 'class', schema: STRING, required: false }, + ], + }, + }), + 'string', + 'pydantic' + ); + expect(out).toContain('from pydantic import BaseModel, ConfigDict, Field'); + expect(out).toContain('class Order(BaseModel):'); + expect(out).toContain('model_config = ConfigDict(populate_by_name=True)'); + expect(out).toContain('id: str'); + expect(out).toContain('class_: Optional[str] = Field(default=None, alias="class")'); + // The alias replaces `_field_map`, and `ClassVar` typed only that map. + expect(out).not.toContain('@dataclass'); + expect(out).not.toContain('_field_map'); + expect(out).not.toContain('ClassVar'); + expect(out).not.toContain('from dataclasses import'); + }); + + it('flattens allOf compositions into one dataclass', () => { + const out = renderPythonModels( + model({ + Base: { + kind: 'object', + properties: [{ name: 'offset', schema: INT, required: false }], + }, + Page: { + kind: 'intersection', + members: [ + { kind: 'ref', name: 'Base' }, + { + kind: 'object', + properties: [ + { name: 'items', schema: { kind: 'array', items: STRING }, required: true }, + ], + }, + ], + }, + }) + ); + expect(out).toContain('@dataclass\nclass Page:'); + expect(out).toContain('items: List[str]'); + expect(out).toContain('offset: Optional[int] = None'); + }); + + it('renders enums with SCREAMING members and unions as aliases with a discriminator table', () => { + const out = renderPythonModels( + model({ + Status: { kind: 'enum', values: ['in-progress', 'done'], scalar: 'string' }, + Cat: { kind: 'object', properties: [] }, + Dog: { kind: 'object', properties: [] }, + Pet: { + kind: 'union', + members: [ + { kind: 'ref', name: 'Cat' }, + { kind: 'ref', name: 'Dog' }, + ], + discriminator: { + propertyName: 'petType', + mapping: [ + { value: 'cat', schemaName: 'Cat' }, + { value: 'dog', schemaName: 'Dog' }, + ], + }, + }, + }) + ); + expect(out).toContain('class Status(str, Enum):'); + expect(out).toContain('IN_PROGRESS = "in-progress"'); + expect(out).toContain('Pet = Union[Cat, Dog]'); + expect(out).toContain('# Discriminated by "petType": cat -> Cat, dog -> Dog'); + expectCompiles(out); + }); + + /** Cat/Dog under a `petType` discriminator; `declares` controls whether they declare it. */ + function petUnion(declares: boolean) { + const member = { + kind: 'object' as const, + properties: declares ? [{ name: 'petType', schema: STRING, required: true }] : [], + }; + return { + Cat: member, + Dog: member, + Pet: { + kind: 'union' as const, + members: [ + { kind: 'ref' as const, name: 'Cat' }, + { kind: 'ref' as const, name: 'Dog' }, + ], + discriminator: { + propertyName: 'petType', + mapping: [ + { value: 'cat', schemaName: 'Cat' }, + { value: 'dog', schemaName: 'Dog' }, + ], + }, + }, + }; + } + + it('pins the discriminator as a Literal so pydantic resolves a nested union', () => { + const out = renderPythonModels(model(petUnion(true)), 'string', 'pydantic'); + expect(out).toContain('pet_type: Literal["cat"] = Field(alias="petType")'); + expect(out).toContain('pet_type: Literal["dog"] = Field(alias="petType")'); + expect(out).toContain('Pet = Annotated[Union[Cat, Dog], Field(discriminator="pet_type")]'); + expect(out).toContain('Annotated'); + expectCompiles(out); + }); + + it('leaves the union plain when its members do not declare the discriminator', () => { + const out = renderPythonModels(model(petUnion(false)), 'string', 'pydantic'); + expect(out).toContain('Pet = Union[Cat, Dog]'); + expect(out).not.toContain('Annotated'); + // Dataclass mode never annotates: it walks the fields and reads the table itself. + const dataclasses = renderPythonModels(model(petUnion(true)), 'string', 'dataclass'); + expect(dataclasses).toContain('Pet = Union[Cat, Dog]'); + expect(dataclasses).toContain('pet_type: str'); + expectCompiles(out); + }); + + it('sanitizes reserved-word field names and records the wire mapping', () => { + const out = renderPythonModels( + model({ + Lesson: { + kind: 'object', + properties: [{ name: 'class', schema: STRING, required: true }], + }, + }) + ); + expect(out).toContain('class_: str'); + expect(out).toContain('"class_": "class"'); + expectCompiles(out); + }); + + it('keeps +1 and -1 fields distinct (a collision silently drops one from the field map)', () => { + const out = renderPythonModels( + model({ + Reactions: { + kind: 'object', + properties: [ + { name: '+1', schema: INT, required: true }, + { name: '-1', schema: INT, required: true }, + ], + }, + }) + ); + expect(out).toContain('plus_1: int'); + expect(out).toContain('minus_1: int'); + expect(out).toContain('"plus_1": "+1"'); + expect(out).toContain('"minus_1": "-1"'); + expectCompiles(out); + }); + + it('renders nullable and record shapes idiomatically', () => { + const out = renderPythonModels( + model({ + Thing: { + kind: 'object', + properties: [ + { + name: 'tag', + schema: { kind: 'union', members: [STRING, { kind: 'null' }] }, + required: true, + }, + { name: 'meta', schema: { kind: 'record', value: STRING }, required: true }, + ], + }, + }) + ); + expect(out).toContain('tag: Optional[str]'); + expect(out).toContain('meta: Dict[str, str]'); + }); +}); + +const CAFE: ApiModel = { + title: 'Cafe', + version: '1.0.0', + serverUrl: 'https://api.cafe.example/organizations/unknown', + servers: [ + { + url: 'https://api.cafe.example/organizations/{organizationId}', + description: 'Live server', + variables: [{ name: 'organizationId', default: 'unknown' }], + }, + { + url: 'https://api-sandbox.cafe.example/organizations/{organizationId}', + description: 'Sandbox server', + variables: [{ name: 'organizationId', default: 'unknown' }], + }, + ], + services: [ + { + name: 'Orders', + operations: [ + { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [ + { name: 'after', in: 'query', required: false, schema: STRING }, + { name: 'limit', in: 'query', required: false, schema: INT }, + ], + headerParams: [], + cookieParams: [], + security: [['BearerAuth']], + paginationExtension: { + style: 'cursor', + cursorParam: 'after', + nextCursor: '/next', + items: '/items', + }, + successResponseHeaders: [ + { + name: 'pagination-total', + schema: { kind: 'scalar', scalar: 'integer' }, + required: true, + }, + { name: 'link', schema: { kind: 'scalar', scalar: 'string' } }, + ], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'OrderPage' }, + }, + ], + errorResponses: [], + }, + { + name: 'streamEvents', + specName: 'streamEvents', + method: 'get', + path: '/events', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'text/event-stream', + schema: { kind: 'object', properties: [] }, + }, + ], + errorResponses: [], + }, + { + name: 'uploadPhoto', + specName: 'uploadPhoto', + method: 'post', + path: '/photos', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + requestBody: { + contentType: 'multipart/form-data', + schema: { kind: 'object', properties: [] }, + }, + successResponses: [{ status: '204', contentType: '', schema: { kind: 'unknown' } }], + errorResponses: [], + }, + { + name: 'getOrder', + specName: 'getOrder', + method: 'get', + path: '/orders/{orderId}', + tags: ['Orders'], + pathParams: [{ name: 'orderId', in: 'path', required: true, schema: STRING }], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + { + name: 'createOrder', + specName: 'createOrder', + method: 'post', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + requestBody: { contentType: 'application/json', schema: { kind: 'ref', name: 'Order' } }, + successResponses: [ + { + status: '201', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + schemas: [ + { + name: 'Order', + schema: { + kind: 'object', + properties: [{ name: 'id', schema: STRING, required: true }], + }, + }, + { + name: 'OrderPage', + schema: { + kind: 'object', + properties: [ + { + name: 'items', + schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, + required: true, + }, + ], + }, + }, + ], + securitySchemes: [{ key: 'BearerAuth', kind: 'bearer' }], +} as unknown as ApiModel; + +function generate(errorMode: 'throw' | 'result' = 'throw'): string { + const files = pythonGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { errorMode }, + }); + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/out/client.py'); + return files[0].content; +} + +describe('python auth keys', () => { + it('accepts apiKey (the documented, cross-language key) and api_key alike', () => { + if (!hasHttpx) return; + const out = pythonGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + })[0].content; + const dir = mkdtempSync(join(tmpdir(), 'py-auth-')); + try { + writeFileSync(join(dir, 'client.py'), out); + const run = spawnSync( + 'python3', + [ + '-c', + 'import client;' + + ' spec = [[{"kind": "apiKey", "scheme": "K", "name": "X-Key", "in": "header"}]];' + + ' print(client.resolve_auth(spec, {"apiKey": {"K": "v"}})[0]);' + + ' print(client.resolve_auth(spec, {"api_key": {"K": "v"}})[0])', + ], + { cwd: dir, encoding: 'utf-8' } + ); + expect(run.status, run.stderr).toBe(0); + expect(run.stdout.trim().split('\n')).toEqual(["{'X-Key': 'v'}", "{'X-Key': 'v'}"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('python output path', () => { + const pathFor = (outputPath: string) => + pythonGenerator({ model: CAFE, outputPath, outputMode: 'single', emit: {} })[0].path; + + it('emits an importable module name — the TypeScript stem is not one', () => { + // `openapi.client.py` and `rebilly-core.client.py` cannot be imported by name. + expect(pathFor('/out/openapi.client.ts')).toBe('/out/openapi_client.py'); + expect(pathFor('/out/rebilly-core.client.ts')).toBe('/out/rebilly_core_client.py'); + // A stem that is already importable is left alone. + expect(pathFor('/out/client.ts')).toBe('/out/client.py'); + // A leading digit would be a syntax error in an import. + expect(pathFor('/out/3rd-party.client.ts')).toBe('/out/_3rd_party_client.py'); + }); +}); + +describe('pythonGenerator (full client assembly)', () => { + it('renders typed sync methods — kwargs for query params, positional path params, hydrated returns', () => { + const out = generate(); + expect(out).toContain('class Client:'); + expect(out).toContain( + 'def list_orders(self, *, after: Optional[str] = None, limit: Optional[int] = None' + ); + expect(out).toContain(') -> OrderPage:'); + expect(out).toContain('def get_order(self, order_id: str, *'); + expect(out).toContain('def create_order(self, body: Order, *'); + expect(out).toContain('return decode(OrderPage, _safe_json(response))'); + // Wire names survive the snake_case kwargs. + expect(out).toContain('params["after"] = encode(after)'); + }); + + it('embeds the runtime, the descriptor table, and an async mirror', () => { + const out = generate(); + expect(out).toContain('def send('); // embedded runtime + expect(out).toContain('async def send_async('); // async mirror + expect(out).toContain('_OPERATIONS = {'); + expect(out).toContain('"id": "listOrders"'); + expect(out).toContain('class AsyncClient:'); + expect(out).toContain('async def list_orders('); + expect(out).not.toContain('from ._'); // relative imports stitched away + }); + + it('raises ApiError in throw mode; returns Result in result mode', () => { + expect(generate('throw')).toContain('raise ApiError('); + const result = generate('result'); + expect(result).toContain(') -> Result:'); + expect(result).toContain('return Result(data=None, error='); + }); + + it('the assembled file is valid Python', () => { + expectCompiles(generate()); + expectCompiles(generate('result')); + }); +}); + +describe('pythonGenerator parity features', () => { + it('paginated operations gain pages/items iterators, sync and async', () => { + const out = generate(); + expect(out).toContain('"pagination": {"style": "cursor", "param": "after"'); + expect(out).toContain('def list_orders_pages('); + expect(out).toContain('def list_orders_items('); + expect(out).toContain('-> Iterator[Order]:'); // typed via schemaAtPointer on the items pointer + expect(out).toContain('iter_pages('); + expect(out).toContain('async for page in aiter_pages('); + expect(out).toContain('-> Iterator[OrderPage]:'); + }); + + it('an iterator takes the path parameters and substitutes them, like the call does', () => { + // Without this the iterator requested the template literally (`/orders/{orderId}/items`) + // and the caller had no argument to pass the value in. + const out = pythonGenerator({ + model: { + title: 'Nested', + version: '1.0.0', + serverUrl: 'https://api.example.com', + schemas: [], + securitySchemes: [], + services: [ + { + name: 'Orders', + operations: [ + { + name: 'listOrderItems', + specName: 'listOrderItems', + method: 'get', + path: '/orders/{orderId}/items', + tags: [], + pathParams: [{ name: 'orderId', in: 'path', required: true, schema: STRING }], + queryParams: [{ name: 'cursor', in: 'query', required: false, schema: STRING }], + headerParams: [], + cookieParams: [], + security: [], + paginationExtension: { + style: 'cursor', + cursorParam: 'cursor', + nextCursor: '/next', + items: '/items', + }, + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'object', properties: [] }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + } as unknown as ApiModel, + outputPath: '/tmp/client.ts', + emit: {}, + outputMode: 'single', + })[0].content; + expect(out).toContain('def list_order_items_pages(self, order_id: str, *, cursor:'); + expect(out).toContain('def list_order_items_items(self, order_id: str, *, cursor:'); + expect(out).toContain('url = build_url(self._server_url, op["path"], {"orderId": order_id})'); + }); + + it('SSE operations stream typed events; multipart bodies route through to_multipart', () => { + const out = generate(); + expect(out).toContain('def stream_events('); + expect(out).toContain('-> Iterator[ServerSentEvent]:'); + expect(out).toContain('iter_sse('); + expect(out).toContain('-> AsyncIterator[ServerSentEvent]:'); + expect(out).toContain('aiter_sse('); + expect(out).toContain('form_data, form_files = to_multipart(body)'); + expect(out).toContain('data=form_data, files=form_files'); + expectCompiles(out); + }); + + it('emits a _with_headers envelope variant only for ops with declared response headers', () => { + const out = generate(); + expect(out).toContain('def list_orders_with_headers('); + expect(out).toContain('async def list_orders_with_headers('); + expect(out).toContain(') -> Envelope[OrderPage]:'); + expect(out).toContain( + 'read_envelope_headers(response, [("pagination-total", "pagination_total", "integer"), ("link", "link", "string")])' + ); + // No declared headers, no variant. + expect(out).not.toContain('get_order_with_headers'); + expectCompiles(out); + }); + + it('maps date/date-time to datetime objects under dateType: Date, and round-trips them', () => { + const dated: ApiModel = { + title: 'Cafe', + version: '1.0.0', + serverUrl: 'https://api.cafe.example', + services: [ + { + name: 'Orders', + operations: [ + { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [ + { + name: 'since', + in: 'query', + required: false, + schema: { kind: 'scalar', scalar: 'string', metadata: { format: 'date-time' } }, + }, + ], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + schemas: [ + { + name: 'Order', + schema: { + kind: 'object', + properties: [ + { + name: 'placedAt', + schema: { kind: 'scalar', scalar: 'string', metadata: { format: 'date-time' } }, + required: true, + }, + { + name: 'dueDate', + schema: { kind: 'scalar', scalar: 'string', metadata: { format: 'date' } }, + required: false, + }, + { + name: 'reminders', + schema: { + kind: 'array', + items: { kind: 'scalar', scalar: 'string', metadata: { format: 'date-time' } }, + }, + required: false, + }, + ], + }, + }, + ], + securitySchemes: [], + } as unknown as ApiModel; + + const out = pythonGenerator({ + model: dated, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { dateType: 'Date' }, + })[0].content; + + expect(out).toContain('from datetime import date, datetime'); + expect(out).toContain('placed_at: datetime'); + expect(out).toContain('due_date: Optional[date] = None'); + // Nested positions must convert too, not just top-level fields. + expect(out).toContain('reminders: Optional[List[datetime]] = None'); + expect(out).toContain('since: Optional[datetime] = None'); + // dateType: string (the default) keeps the wire representation. + const asString = pythonGenerator({ + model: dated, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + })[0].content; + expect(asString).toContain('placed_at: str'); + expect(asString).not.toContain('placed_at: datetime'); + expectCompiles(out); + + // Behavioral: the runtime decodes ISO strings into objects and encodes them back. + if (!hasHttpx) return; + const dir = mkdtempSync(join(tmpdir(), 'py-dates-')); + try { + writeFileSync(join(dir, 'client.py'), out); + const run = spawnSync( + 'python3', + [ + '-c', + 'import client;' + + ' o = client.decode(client.Order, {"placedAt": "2026-08-05T10:00:00+00:00", "dueDate": "2026-08-06", "reminders": ["2026-08-07T12:00:00+00:00"]});' + + ' print(type(o.placed_at).__name__, type(o.due_date).__name__, type(o.reminders[0]).__name__);' + + ' print(client.encode(o))', + ], + { cwd: dir, encoding: 'utf-8' } + ); + expect(run.status, run.stderr).toBe(0); + expect(run.stdout).toContain('datetime date datetime'); + expect(run.stdout).toContain('2026-08-06'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('bakes the serverUrl option, not just the description server', () => { + const files = pythonGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { serverUrl: 'https://override.example' }, + }); + expect(files[0].content).toContain('server_url: str = "https://override.example"'); + }); + + it('emits a Servers class with keyword arguments defaulting to the spec defaults', () => { + const out = generate(); + expect(out).toContain('class Servers:'); + expect(out).toContain('def live_server(organization_id: str = "unknown") -> str:'); + expect(out).toContain('def sandbox_server(organization_id: str = "unknown") -> str:'); + expect(out).toContain('return "https://api.cafe.example/organizations/" + organization_id'); + expectCompiles(out); + }); + + it('decodes discriminated unions through the DISCRIMINATORS registry', () => { + const files = pythonGenerator({ + model: { + title: 'Pets', + version: '1.0.0', + serverUrl: 'https://pets.example', + services: [], + schemas: [ + { name: 'Cat', schema: { kind: 'object', properties: [] } }, + { + name: 'Dog', + schema: { + kind: 'object', + properties: [{ name: 'barks', schema: { kind: 'scalar', scalar: 'boolean' } }], + }, + }, + { + name: 'Pet', + schema: { + kind: 'union', + members: [ + { kind: 'ref', name: 'Cat' }, + { kind: 'ref', name: 'Dog' }, + ], + discriminator: { + propertyName: 'petType', + mapping: [ + { value: 'cat', schemaName: 'Cat' }, + { value: 'dog', schemaName: 'Dog' }, + ], + }, + }, + }, + ], + securitySchemes: [], + } as unknown as ApiModel, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + }); + const out = files[0].content; + expect(out).toContain('DISCRIMINATORS[Pet] = ("petType", {"cat": Cat, "dog": Dog})'); + // Behavioral: first-member-wins would hydrate {"petType": "dog"} as Cat (empty + // dataclasses accept anything); the registry must dispatch it to Dog. + if (!hasHttpx) return; + const dir = mkdtempSync(join(tmpdir(), 'py-dispatch-')); + try { + writeFileSync(join(dir, 'client.py'), out); + const run = spawnSync( + 'python3', + [ + '-c', + 'import client; print(type(client.decode(client.Pet, {"petType": "dog"})).__name__)', + ], + { cwd: dir, encoding: 'utf-8' } + ); + expect(run.status, run.stderr).toBe(0); + expect(run.stdout.trim()).toBe('Dog'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/client-generator/src/generators/__tests__/resolve.test.ts b/packages/client-generator/src/generators/__tests__/resolve.test.ts index d17bff8146..3312fd9429 100644 --- a/packages/client-generator/src/generators/__tests__/resolve.test.ts +++ b/packages/client-generator/src/generators/__tests__/resolve.test.ts @@ -1,6 +1,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { GENERATOR_VERSION } from '../compatibility.js'; import { resolveGenerators } from '../resolve.js'; import type { CustomGenerator } from '../types.js'; @@ -10,33 +11,108 @@ const noopRun = () => []; describe('resolveGenerators', () => { it('passes built-in names through unchanged', async () => { - const { selected, registry } = await resolveGenerators(['sdk', 'zod']); - expect(selected).toEqual(['sdk', 'zod']); - expect(registry.has('sdk')).toBe(true); + const { selected, registry } = await resolveGenerators(['typescript', 'zod']); + expect(selected).toEqual(['typescript', 'zod']); + expect(registry.has('typescript')).toBe(true); expect(registry.has('zod')).toBe(true); }); + it('names the rename for the retired "sdk" entry instead of importing it as a package', async () => { + await expect(resolveGenerators(['sdk'])).rejects.toThrow( + 'The "sdk" generator is now named "typescript"' + ); + }); + + it("keeps a registered generator's declared options schema", async () => { + const custom: CustomGenerator = { + name: 'route-map', + run: noopRun, + options: { type: 'object', properties: { exportName: { type: 'string' } } }, + }; + const { registry } = await resolveGenerators(['route-map'], { customGenerators: [custom] }); + expect(registry.get('route-map')?.options).toEqual(custom.options); + }); + 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(['sdk', 'route-map'], { + const { selected, registry } = await resolveGenerators(['typescript', 'route-map'], { customGenerators: [custom], }); - expect(selected).toEqual(['sdk', 'route-map']); + expect(selected).toEqual(['typescript', 'route-map']); expect(registry.get('route-map')?.run).toBe(noopRun); }); + it('pulls in a generator prerequisite instead of failing on it', async () => { + // `--generator cli` alone should produce a working, validating CLI. + const { selected } = await resolveGenerators(['cli']); + expect(selected).toContain('cli'); + expect(selected).toContain('typescript'); + expect(selected).toContain('zod'); + // A prerequisite runs BEFORE the generator that needs it. + expect(selected.indexOf('typescript')).toBeLessThan(selected.indexOf('cli')); + // An explicit selection is not duplicated or reordered away. + const explicit = await resolveGenerators(['typescript', 'zod', 'cli']); + expect(explicit.selected).toEqual(['typescript', 'zod', 'cli']); + }); + + it('accepts a generator whose requiresGenerator range covers the running version', async () => { + const [major, minor] = GENERATOR_VERSION.split('.'); + const covering: CustomGenerator = { + name: 'ok', + run: noopRun, + requiresGenerator: `^${major}.${minor}.0`, + }; + await expect(resolveGenerators(['ok'], { customGenerators: [covering] })).resolves.toBeTruthy(); + + // A generator written against a newer toolkit than this CLI ships. + const ahead: CustomGenerator = { + name: 'ahead', + run: noopRun, + requiresGenerator: `>=${Number(major) + 1}.0.0`, + }; + await expect(resolveGenerators(['ahead'], { customGenerators: [ahead] })).rejects.toThrow( + new RegExp( + `"ahead" needs @redocly/client-generator >=${Number(major) + 1}\\.0\\.0.*this CLI ships ${GENERATOR_VERSION}`, + 's' + ) + ); + + // A generator pinned to a toolkit older than the one running: update the generator. + const behind: CustomGenerator = { name: 'behind', run: noopRun, requiresGenerator: '0.0.1' }; + await expect(resolveGenerators(['behind'], { customGenerators: [behind] })).rejects.toThrow( + /eject-generator/ + ); + + // An unreadable range is rejected as such — never guessed at. + const vague: CustomGenerator = { name: 'vague', run: noopRun, requiresGenerator: '1.x || 2' }; + await expect(resolveGenerators(['vague'], { customGenerators: [vague] })).rejects.toThrow( + /requiresGenerator "1.x \|\| 2", which is not a range we read/ + ); + + // No declaration keeps friction-free authoring — accepted as current. + const undeclared: CustomGenerator = { name: 'bare', run: noopRun }; + await expect( + resolveGenerators(['bare'], { customGenerators: [undeclared] }) + ).resolves.toBeTruthy(); + }); + it('registers an inline custom that is available (for requires) but not selected', async () => { const custom: CustomGenerator = { name: 'extra', run: noopRun }; - const { selected, registry } = await resolveGenerators(['sdk'], { customGenerators: [custom] }); - expect(selected).toEqual(['sdk']); + const { selected, registry } = await resolveGenerators(['typescript'], { + customGenerators: [custom], + }); + expect(selected).toEqual(['typescript']); expect(registry.has('extra')).toBe(true); }); - it('rejects a custom generator whose name collides with a built-in', async () => { - const custom: CustomGenerator = { name: 'sdk', run: noopRun }; - await expect(resolveGenerators(['sdk'], { customGenerators: [custom] })).rejects.toThrow( - /collides/ - ); + it('a custom generator may take over a built-in name (ejected generators shadow their origin)', async () => { + const custom: CustomGenerator = { name: 'python', run: noopRun, sample: () => undefined }; + const { selected, registry } = await resolveGenerators(['python'], { + customGenerators: [custom], + }); + expect(selected).toEqual(['python']); + expect(registry.get('python')?.run).toBe(noopRun); + expect(typeof registry.get('python')?.sample).toBe('function'); }); it('rejects two custom generators with the same name', async () => { @@ -55,11 +131,23 @@ describe('resolveGenerators', () => { }); it('loads a generator from a relative path specifier and selects its declared name', async () => { - const { selected, registry } = await resolveGenerators(['sdk', './route-map-plugin.ts'], { + const { selected, registry } = await resolveGenerators( + ['typescript', './route-map-plugin.ts'], + { + configDir: fixtures, + } + ); + expect(selected).toEqual(['typescript', 'route-map']); + expect(registry.has('route-map')).toBe(true); + }); + + it('pulls in the prerequisite a path-loaded generator declares', async () => { + // The specifier has to be imported before its `requires` is known, so an ejected + // generator gets its prerequisites the same way the built-in name does. + const { selected } = await resolveGenerators(['./route-map-plugin.ts'], { configDir: fixtures, }); - expect(selected).toEqual(['sdk', 'route-map']); - expect(registry.has('route-map')).toBe(true); + expect(selected).toEqual(['typescript', 'route-map']); }); it('rejects URL specifiers — remote generator modules are not supported', async () => { 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 new file mode 100644 index 0000000000..2966209819 --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/runtime-embed-freshness.test.ts @@ -0,0 +1,38 @@ +// The hand-written language runtimes are embedded as strings at prepare time +// (scripts/generate-runtime-sources.mjs). Editing a runtime file WITHOUT re-running +// prepare ships a stale runtime: the generator's own unit bars still pass (they assert +// on generated declarations, not runtime behavior), so the mismatch only surfaces at +// the compile bar — or in a user's client. This pins snapshot == source. + +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'; + +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'); + expect(GO_RUNTIME_SOURCE, STALE).toBe(source); + }); + + it('php', () => { + const source = readFileSync(join(pkgRoot, 'runtime/php/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 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; + for (const name of onDisk) { + expect(embedded[name], `${name}: ${STALE}`).toBe(readFileSync(join(dir, name), 'utf-8')); + } + }); +}); diff --git a/packages/client-generator/src/generators/__tests__/swr.test.ts b/packages/client-generator/src/generators/__tests__/swr.test.ts index 0af00c52f6..818893d1b0 100644 --- a/packages/client-generator/src/generators/__tests__/swr.test.ts +++ b/packages/client-generator/src/generators/__tests__/swr.test.ts @@ -1,6 +1,6 @@ import { apiModel, operation } from '../../emitters/__tests__/fixtures.js'; import { builtinGenerators } from '../index.js'; -import { swrGenerator } from '../swr.js'; +import { swrGenerator } from '../swr/index.js'; const SERVICES = [ { 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 ffcc3642b0..7e188b6e2a 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,6 @@ import { apiModel, operation } from '../../emitters/__tests__/fixtures.js'; import { builtinGenerators } from '../index.js'; -import { tanstackQueryGenerator } from '../tanstack-query.js'; +import { tanstackQueryGenerator } from '../tanstack-query/index.js'; const SERVICES = [ { @@ -77,7 +77,7 @@ describe('tanstackQueryGenerator', () => { 'tanstack-query-solid', ]) { const descriptor = registry.get(name); - expect(descriptor?.requires, name).toEqual(['sdk']); + expect(descriptor?.requires, name).toEqual(['typescript']); expect(descriptor?.errorModes, name).toEqual(['throw']); } }); diff --git a/packages/client-generator/src/generators/__tests__/transformers.test.ts b/packages/client-generator/src/generators/__tests__/transformers.test.ts index df137fade4..52b8c2d3c0 100644 --- a/packages/client-generator/src/generators/__tests__/transformers.test.ts +++ b/packages/client-generator/src/generators/__tests__/transformers.test.ts @@ -1,6 +1,6 @@ import { apiModel, namedSchema } from '../../emitters/__tests__/fixtures.js'; import { builtinGenerators } from '../index.js'; -import { transformersGenerator } from '../transformers.js'; +import { transformersGenerator } from '../transformers/index.js'; const EVENT = namedSchema('Event', { kind: 'object', diff --git a/packages/client-generator/src/generators/__tests__/sdk.test.ts b/packages/client-generator/src/generators/__tests__/typescript.test.ts similarity index 92% rename from packages/client-generator/src/generators/__tests__/sdk.test.ts rename to packages/client-generator/src/generators/__tests__/typescript.test.ts index 4f498978d9..db6fb923e0 100644 --- a/packages/client-generator/src/generators/__tests__/sdk.test.ts +++ b/packages/client-generator/src/generators/__tests__/typescript.test.ts @@ -1,6 +1,6 @@ import { HEADER } from '../../emitters/emit-options.js'; import type { ApiModel } from '../../intermediate-representation/model.js'; -import { sdkGenerator } from '../sdk.js'; +import { typescriptGenerator } from '../typescript/index.js'; function apiModel(): ApiModel { return { @@ -32,9 +32,9 @@ function apiModel(): ApiModel { }; } -describe('sdkGenerator', () => { +describe('typescriptGenerator', () => { it('writes the whole client to the output path in single mode', () => { - const files = sdkGenerator({ + const files = typescriptGenerator({ model: apiModel(), outputPath: '/out/api.ts', outputMode: 'single', @@ -48,7 +48,7 @@ describe('sdkGenerator', () => { it('honors the output mode (split carves the schemas into a sibling file)', () => { const model = apiModel(); model.schemas = [{ name: 'Thing', schema: { kind: 'object', properties: [] } }]; - const files = sdkGenerator({ + const files = typescriptGenerator({ model, outputPath: '/out/api.ts', outputMode: 'split', @@ -69,7 +69,7 @@ describe('sdkGenerator', () => { model.services[0].operations[0].successResponses = [ { contentType: 'application/json', schema: { kind: 'ref', name: 'Pet' }, status: 200 }, ]; - const files = sdkGenerator({ + const files = typescriptGenerator({ model, outputPath: '/out/api.ts', outputMode: 'split', @@ -84,7 +84,7 @@ describe('sdkGenerator', () => { it('emits .ts import extensions when importExt is ts (Node native TS execution)', () => { const model = apiModel(); model.schemas = [{ name: 'Thing', schema: { kind: 'object', properties: [] } }]; - const files = sdkGenerator({ + const files = typescriptGenerator({ model, outputPath: '/out/api.ts', outputMode: 'split', diff --git a/packages/client-generator/src/generators/__tests__/zod.test.ts b/packages/client-generator/src/generators/__tests__/zod.test.ts index 314381615a..d6029cee6a 100644 --- a/packages/client-generator/src/generators/__tests__/zod.test.ts +++ b/packages/client-generator/src/generators/__tests__/zod.test.ts @@ -1,5 +1,5 @@ import { apiModel, namedSchema } from '../../emitters/__tests__/fixtures.js'; -import { zodGenerator } from '../zod.js'; +import { zodGenerator } from '../zod/index.js'; const PET = namedSchema('Pet', { kind: 'object', diff --git a/packages/client-generator/src/generators/cli/AGENTS.md b/packages/client-generator/src/generators/cli/AGENTS.md new file mode 100644 index 0000000000..90f226c873 --- /dev/null +++ b/packages/client-generator/src/generators/cli/AGENTS.md @@ -0,0 +1,109 @@ +# The `cli` generator — its skill + +This file is the generator's DESIGN and governs our own changes: **to change the +generator, edit this skill first, then make the code match it.** + +`npm run prepare` compiles it into `eject-assets/skills/cli-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + +## What it emits + +A bin-ready `.cli.ts`: one command per operation over the sdk's instance client, +with `--help`, a `schema ` introspection command, and `--dry-run`. + +With `client.docs` (or `--docs`), the `docs` hook also writes `.cli.md`: the usage +line, the global flags, the credential variables, the exit-code table, and one section per +command with its positionals and flags. + +## Design decisions that must hold + +- **Argument shape:** path params positional, query params typed `--kebab-name` flags, + JSON bodies via `--json '' | @file | @-` (stdin). +- **Help is the whole interface.** A flag that exists but isn't in `--help` doesn't exist + to the user, so the top-level help carries a `Global flags:` section (`--server-url`, + `--format`, `--dry-run`, `--page-all`, `--output`, `--token`, `--json`) plus the + credential environment variables. Descriptions are collapsed to ONE line — an OpenAPI + description with newlines otherwise breaks the alignment of every following flag. The + footer names the form that actually works for a grouped API + (` --help`). +- **Commands are addressable the way a shell allows.** A group slug is kebab-cased so a + multi-word OpenAPI tag can be typed without quoting, while help shows the original tag. + A bare operationId resolves to its grouped command when unambiguous. +- **Exit codes are a contract:** 0 ok, 1 API error, 2 auth, 3 validation, 4 usage. + Errors print ONE JSON object to stderr so stdout stays pipeable. +- **The CLI names itself from `process.argv[1]`.** Only the operator's `bin` field decides + what the command is called, so help reads the invoked name back instead of printing a + name from generation that may not exist on the machine. +- **Credentials come from the environment** — `wiring.envPrefix`, the constant-cased output + stem (`CLIENT_TOKEN`), which a composed entry sets per api alias — or explicit flags; + `--dry-run` prints the prepared request with credentials REDACTED. The prefix is fixed at + generation on purpose: a renamed binary must keep reading the variables a published CLI + already documents. Help lists only the credentials the description declares, and an + unusable `--token` is a usage error, never silently dropped. +- **Validation is on by default.** The generator declares `requires: ['typescript', 'zod']` and + the pipeline pulls prerequisites in automatically, so `--generator cli` alone produces a + validating CLI — a user shouldn't have to know which other generator provides it. The + consequence is a zod peer dependency at run time, which the docs state. +- Throw-mode only — the exit-code mapping reads thrown `ApiError`s. +- **Runs under `node --experimental-strip-types` with no build step**, including the + modules it imports (the sdk and the zod module). Anything emitted must be erasable + TypeScript; a parameter property anywhere in that import graph breaks the zero-build + runner. +- **The generated module is a library as well as a binary.** It exports `COMMANDS`, + `wiring`, and `run`, and self-executes only when it is the process entry — a REALPATH + comparison of `import.meta.url` against `argv[1]`, because some runners resolve + symlinks in one but not the other (macOS temp dirs, installed bin symlinks), and a + plain URL comparison silently runs nothing. `import.meta.main` would be cleaner but is + absent from our Node floors. Importing the module must be side-effect-safe: + module-level wiring (zod validation) touches only the module's OWN client, never a + global. +- **Behavior that is not in the description is composed, never generated.** A custom + command (`login`, anything) is the operation-command data shape plus a `handler`, so it + inherits help, parsing, `schema`, and the exit-code contract; `runCli` dispatches it + instead of the client. The generator itself never learns what such a command does — + credentials files, login flows, and profiles are user land (or a future satellite), + by design. +- **One binary can span several descriptions.** `runCli` also accepts sources — each a + command list plus, optionally, its OWN wiring (own base URL, schemes, credentials) + behind a namespace, so colliding operationIds across descriptions are simply different + commands (`cafe shop createOrder`, `cafe kitchen createOrder`). A namespace-less source + puts commands at the root (`cafe login`); a root command whose name matches a namespace + is rejected at startup, never shadowed. A source WITHOUT wiring inherits the first + wired source's — a root `login` shares the composed binary's identity, which is the + whole point of composing it there. +- **The composed entry is generated, not hand-rolled.** A top-level `client.cliOutput` + makes `redocly generate-client` (no api argument) emit one entry over every api that + selected `cli`: the namespace is the api ALIAS from `apis:`, and the credential prefix + defaults to `_` (`CAFE_SHOP_TOKEN`) via `wiring.envPrefix` — which + exists precisely so the display name and the credential prefix can differ. The composed + entry exports its `SOURCES` so an adopter layers custom commands around it without + editing a generated file. Without `cliOutput`, nothing changes. + +- **The CLI documents itself.** The page is this generator's `docs` hook, not a separate + generator: nothing else knows this tool's commands, and a reader who ejects `cli` gets + the page layout with it. The page renders from `commandData` — the same table `runCli` + dispatches on — so it cannot describe a tool other than the one beside it. A capability + reaches the page only by being in that table. The page is Markdown that survives a + linter (ATX headings, a blank line around every block, no hard tabs, one sentence per + line) and it escapes what descriptions contain, because a summary is arbitrary text. + +## Emitters that implement it + +`emitters/cli.ts` (commands + module) and `emitters/cli-docs.ts` (the page), plus the +sdk's operation types. + +## Ejecting it + +`redocly eject-generator cli` ships this generator BUNDLED with the emitters it uses — one +`.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. Change +the command surface, the help layout, or the exit-code mapping, and regenerate. The exit +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. Change the emitter modules named above (the entry is plumbing — it rarely moves). +3. Verify: `npm run compile`, the emitter unit suites + (`VITEST_SUITE=unit npx vitest run packages/client-generator/src/emitters`), the e2e + suites for this generator, and the large-description bars + (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/cli/index.ts b/packages/client-generator/src/generators/cli/index.ts new file mode 100644 index 0000000000..2f63003d0a --- /dev/null +++ b/packages/client-generator/src/generators/cli/index.ts @@ -0,0 +1,65 @@ +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'; + +/** + * The cli generator: a bin-ready `.cli.ts` — a zero-dependency, typed + * command-line interface over the sibling client (typed flags, `--json` + * 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); + const content = renderCliModule(model, { + stem, + importExt: emit.importExt ?? 'js', + runtime: emit.runtime ?? 'inline', + zodSelected: selected?.includes('zod') ?? false, + pagination: emit.pagination, + argsStyle: emit.argsStyle ?? 'grouped', + }); + return [{ path: join(dir, `${stem}.cli.ts`), content }]; +}; + +/** + * The CLI's own reference page, written when `client.docs` is on: the usage line, the + * global flags, the credential variables, the exit codes, and one section per command. + * 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 }), { + title: `${model.title} command-line reference`, + frontmatter: emit.docsFrontmatter === true, + name: stem, + schemes: cliAuthSchemes(model), + }); + return [{ path: join(dir, `${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( + (candidate) => candidate.name === op.name + ); + if (command === undefined) return undefined; + const words = [ + 'client', + ...(command.group ? [groupSlug(command.group)] : []), + command.name, + ...command.positionals.map((positional) => `<${positional.name}>`), + ...command.flags.filter((flag) => flag.required).map((flag) => `--${flag.name} <${flag.type}>`), + ...(command.body ? ["--json ''"] : []), + ]; + return { + lang: 'shell', + label: 'CLI', + source: `npx tsx client.cli.ts ${words.slice(1).join(' ')}\n`, + }; +} diff --git a/packages/client-generator/src/generators/compatibility.ts b/packages/client-generator/src/generators/compatibility.ts new file mode 100644 index 0000000000..fa438d5d4a --- /dev/null +++ b/packages/client-generator/src/generators/compatibility.ts @@ -0,0 +1,44 @@ +// Generator compatibility is the package version under semver: the API model and the +// authoring helpers ARE the contract, and a breaking change to either bumps the major +// (the minor while the package is 0.x). A generator declares the range it was written +// against with `requiresGenerator`, and a CLI outside that range refuses to run it. + +import packageJson from '../../package.json' with { type: 'json' }; + +/** The `@redocly/client-generator` version providing the model and helpers right now. */ +export const GENERATOR_VERSION: string = packageJson.version; + +type Semver = [major: number, minor: number, patch: number]; + +function parse(version: string): Semver | undefined { + // A prerelease (`2.0.0-snapshot.3`) is treated as its release version: snapshots exist + // to test the release they precede, so they must satisfy the same ranges. + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version.trim()); + return match === null ? undefined : [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function compare(left: Semver, right: Semver): number { + return left[0] - right[0] || left[1] - right[1] || left[2] - right[2]; +} + +/** + * Whether `version` satisfies `range`, for the four forms a generator may declare: + * `^1.2.0`, `~1.2.0`, `>=1.2.0`, and an exact `1.2.0`. `undefined` means the range + * isn't one of those — the caller reports that instead of guessing an answer, since a + * misread range would either block a working generator or admit a broken one. + */ +export function satisfiesGeneratorRange(version: string, range: string): boolean | undefined { + const operator = /^[\^~]|^>=/.exec(range.trim())?.[0] ?? ''; + const lower = parse(range.trim().slice(operator.length)); + const actual = parse(version); + if (lower === undefined || actual === undefined) return undefined; + if (compare(actual, lower) < 0) return false; + if (operator === '>=') return true; + if (operator === '') return compare(actual, lower) === 0; + if (operator === '~') return actual[0] === lower[0] && actual[1] === lower[1]; + // Caret keeps the leftmost NON-ZERO position fixed: ^1.2.0 allows any 1.x, ^0.2.1 allows + // 0.2.x, ^0.0.3 allows only 0.0.3. + if (lower[0] !== 0) return actual[0] === lower[0]; + if (lower[1] !== 0) return actual[0] === 0 && actual[1] === lower[1]; + return compare(actual, lower) === 0; +} diff --git a/packages/client-generator/src/generators/go/AGENTS.md b/packages/client-generator/src/generators/go/AGENTS.md new file mode 100644 index 0000000000..a3b20e5784 --- /dev/null +++ b/packages/client-generator/src/generators/go/AGENTS.md @@ -0,0 +1,94 @@ +# The `go` generator — its skill + +This file is the generator's DESIGN. It ships to users on `redocly eject-generator go` +(as the `.claude/skills/go-generator/SKILL.md` agent skill) and governs our own changes: **to change the generator, +edit this skill first, then make the code match it** — a diff to `index.ts` that has no +covering sentence here is incomplete. + +`npm run prepare` compiles it into `eject-assets/skills/go-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + +## What it emits + +One self-contained `.go` (`package client`): structs with `json` tags, a `Client` +with one `(T, error)` method per operation taking a `context.Context`, and the embedded +runtime. Go ≥ 1.21, standard library only — zero dependencies. + +## Design decisions that must hold + +- **Models are structs**: required fields by value, optionals as pointers with + `,omitempty`; the `json` tag always carries the exact wire name. +- **Package clause:** `package client` by default, `goPackage` to override — a generated + file usually lands in a package the consumer already owns. The value is checked against + Go's own rule (lowercase letters, digits, `_`, no leading digit, not a keyword) and an + invalid one fails generation: silently rewriting a publisher's package name would be + worse than saying no. +- **Doc comments are gofmt's shape**, not the description's: a blank line prints as `//` + (never `// `, which gofmt strips), and CONSECUTIVE blank lines collapse to one — gofmt + rewrites `//\n//` to a single `//`, so emitting both means our output is not + gofmt-clean. Descriptions with a double blank line are common in real specs. +- **Every parameter is its own argument, so their names share one namespace** with the + arguments the method declares itself (`ctx`, `body`, `params`, and the receiver). Build them with + `uniqueIdentifiers(..., { taken: … })`: OpenAPI lets one operation use a name in two + locations (`id` in the path AND in the query), and Go rejects a duplicate parameter. The + wire name is untouched, so the request is unchanged. +- **Naming:** exported PascalCase via `identifierFor` + an `N` prefix for digit-leading + names (`3ds` → `N3ds` — an `_`-prefixed field is unexported and invisible to + `encoding/json`); `+1`/`-1` become `Plus1`/`Minus1`. +- **Enums** are typed consts (`type Status string` + `StatusInProgress Status = …`); + **discriminated unions** are `type X = any` plus a generated `UnmarshalX([]byte)` + dispatcher; **allOf** is flattened. +- **Errors:** `(T, error)` returns ARE the error mode — `errorMode` does not change the + output (the generator declares `errorModes: ['throw']`, so `result` fails fast). + Non-2xx → `*APIError`; timeouts → `*TimeoutError`. +- **Dates:** `dateType: Date` maps `format: date-time` to `time.Time` (encoding/json + handles RFC 3339 natively) and `date` to the runtime's `Date` wrapper, which + marshals as `2006-01-02`. Query values format explicitly, never via `String()`. +- **Response headers:** an operation that DECLARES success-response headers gains a + `WithHeaders(ctx, …) (T, Headers, error)` variant; `Headers` is a + generated struct with pointer fields (nil when absent or unparsable), coerced to + int64/bool/string. Operations without declared headers get no variant, and the + base method stays `(T, error)`. +- **Servers:** when the description declares servers, one `URL(...)` function per + server is emitted (named from the server description); server VARIABLES become string + parameters (Go has no defaults — the doc comment states the spec default), so templated + base URLs need no manual string building. The client's baked default stays `servers[0]` + with variable defaults substituted. +- **Parity surface:** auth, retries with `Retry-After` + jittered backoff, per-attempt + `context.WithTimeout`, idempotency keys, middleware, pagination (`Pages`/`Items` + as `func(yield func(T, error) bool)` — `range`-over-func needs Go ≥ 1.23; 1.21 calls + them with a callback), SSE, multipart. +- **The EMITTED FILE is gofmt-clean, not just the runtime.** `gofmt -l` on generated + output must print nothing, so the download is idiomatic as-is. The emitter earns that + deterministically, without shelling out to `gofmt`: + - `alignGoColumns` pads columns the way gofmt's tabwriter does — struct field types and + tags, `const`/`var` types and `=`, and map-literal values — within each contiguous run. + A line starting with a Go KEYWORD is a statement, never a declaration, and must never + be padded (`case "x":` is not a field). + - `case` sits at its `switch`'s own indent, so the switch body is not emitted as an + indented block. + - At most one blank line between declarations, none at end of file, and a blank line + 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) + and embedded at prepare time. +- Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. + +- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes + `.go.md`: the security schemes, then one section per operation with its parameters, + body, response type, and behavior notes. The call snippets come from this generator's own + `sample` hook, so the page can only show the syntax of the SDK beside it, and the layout + comes from `renderReferencePage` in the authoring toolkit — reachable from an ejected copy + through `@redocly/client-generator`. Pagination on the page is decided by + `paginationRuleFor`, the same helper this generator resolves pagination with. + +## 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` + + `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` + (real `go build` + `go vet` bars), the e2e smoke (`tests/e2e/generate-client/go.test.ts`), + and the large-description bars (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts new file mode 100644 index 0000000000..c7653ec073 --- /dev/null +++ b/packages/client-generator/src/generators/go/index.ts @@ -0,0 +1,1168 @@ +// The built-in `go` generator — the second non-TypeScript library entry, +// authored with the language-neutral toolkit only (same dogfooding invariant as +// 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. + +import { + casing, + Printer, + discriminatorCases, + docText, + enumValues, + flattenAllOf, + headerCoerceType, + identifierFor, + uniqueIdentifiers, + isNullable, + NotSupportedError, + paginationRuleFor, + renderReferencePage, + RESERVED_WORDS, + schemaAtPointer, + unwrapNullable, + type DateType, + 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'); +} + +/** Strip the package clause and import lines/blocks so a section stitches into one file. */ +function stripHeader(source: string): string { + const lines = source.split('\n'); + const out: string[] = []; + let inImportBlock = false; + for (const line of lines) { + if (line.startsWith('package ')) continue; + if (line.startsWith('import (')) { + inImportBlock = true; + continue; + } + if (inImportBlock) { + if (line.startsWith(')')) inImportBlock = false; + continue; + } + if (line.startsWith('import ')) continue; + out.push(line); + } + 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']; + +/** + * 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. + */ +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, + dateType: DateType, + model?: ApiModel, + envelope = false +): 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(); + + printer.block( + 'type operationMeta struct {', + () => { + printer.line('ID string'); + printer.line('Method string'); + printer.line('Path string'); + printer.line('Security [][]SecuritySpec'); + printer.line('Pagination *PaginationSpec'); + }, + '}' + ); + printer.blank(); + printer.block( + 'var operations = map[string]operationMeta{', + () => { + for (const { op, ident } of goOperationIdents(model)) { + const id = op.specName ?? op.name; + 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)}`, + ...(security !== undefined ? [`Security: ${security}`] : []), + ...(rule !== undefined ? [`Pagination: ${goPaginationLiteral(rule)}`] : []), + ]; + printer.line(`${JSON.stringify(id)}: {${fields.join(', ')}},`); + } + }, + '}' + ); + printer.blank(); + + // Per-operation query-parameter structs (pointer fields: absent = not sent). + for (const { op, ident } of goOperationIdents(model)) { + if (op.queryParams.length === 0) continue; + printer.block( + `type ${ident}Params struct {`, + () => { + for (const param of op.queryParams) { + const fieldType = goType(param.schema, dateType); + printer.line( + `${exported(param.name)} ${fieldType.startsWith('*') ? fieldType : `*${fieldType}`}` + ); + } + }, + '}' + ); + printer.blank(); + } + + writeDocComment(printer, 'Client', `Client for ${model.title} (${model.version}).`); + printer.block( + 'type Client struct {', + () => { + printer.line('config Config'); + }, + '}' + ); + printer.blank(); + printer.block( + 'func New(config Config) *Client {', + () => { + printer.block( + 'if config.ServerURL == "" {', + () => { + printer.line( + `config.ServerURL = ${JSON.stringify(emit.serverUrl ?? model.serverUrl ?? '')}` + ); + }, + '}' + ); + printer.line('return &Client{config: config}'); + }, + '}' + ); + printer.blank(); + + for (const { op, ident } of goOperationIdents(model)) { + writeGoMethod(printer, op, ident, dateType); + if (sseResponse(op) === undefined && (op.successResponseHeaders?.length ?? 0) > 0) { + writeGoMethod(printer, op, ident, dateType, model, true); + } + const rule = paginationRules.get(ident); + if (rule === undefined) continue; + const success = successSchema(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; + writeGoPaginationWrappers( + printer, + op, + ident, + dateType, + pageType, + element === undefined ? 'any' : goType(element, dateType) + ); + } + + return [ + { + 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())), + }, + ]; +}; + +/** One idiomatic Go call per operation — feeds `x-codeSamples` for docs. */ +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); + const args = [ + 'ctx', + ...op.pathParams.map( + (param) => `"<${identifierFor(param.name, { style: 'camel', reserved: GO })}>"` + ), + ...(op.requestBody ? [`${goType(op.requestBody.schema, dateType)}{ /* … */ }`] : []), + ...(op.queryParams.length > 0 ? ['nil'] : []), + ]; + return { + lang: 'go', + label: 'Go SDK', + source: `client := ${pkg}.New(${pkg}.Config{})\nresult, err := client.${ident}(${args.join(', ')})\n`, + }; +} + +/** + * The SDK's own reference page, written when `client.docs` is on. The call snippets come + * 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 }) => [ + { + path: outputPath.replace(/\.[^.\\/]+$/, '.go.md'), + content: renderReferencePage(model, { + title: `${model.title} Go SDK reference`, + frontmatter: emit.docsFrontmatter === true, + language: { + name: 'go', + label: 'Go', + fence: 'go', + requires: 'The SDK needs the standard library only.', + }, + sample: (op) => goSample(op, { model, emit, outputPath }), + pagination: emit.pagination, + }), + }, +]; diff --git a/packages/client-generator/src/generators/index.ts b/packages/client-generator/src/generators/index.ts index 7613eac5be..c101c6fec7 100644 --- a/packages/client-generator/src/generators/index.ts +++ b/packages/client-generator/src/generators/index.ts @@ -1,12 +1,16 @@ import type { EmitOptions } from '../emitters/emit-options.js'; -import { NotSupportedError } from '../errors.js'; -import { mockGenerator } from './mock.js'; -import { sdkGenerator } from './sdk.js'; -import { swrGenerator } from './swr.js'; -import { tanstackQueryGenerator } from './tanstack-query.js'; -import { transformersGenerator } from './transformers.js'; -import type { GeneratorDescriptor, GeneratorName } from './types.js'; -import { zodGenerator } from './zod.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'; +import { mockGenerator } from './mock/index.js'; +import { phpDocs, phpGenerator, phpSample } from './php/index.js'; +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 { typescriptDocs, typescriptGenerator, typescriptSample } from './typescript/index.js'; +import { zodGenerator } from './zod/index.js'; export type { CustomGenerator, @@ -16,30 +20,32 @@ export type { GeneratorName, } from './types.js'; -function tanstackQuery(framework: 'react' | 'vue' | 'svelte' | 'solid'): GeneratorDescriptor { - return { run: tanstackQueryGenerator(framework), requires: ['sdk'], errorModes: ['throw'] }; -} - -const GENERATORS: Record = { - // sdk is the base client; zod emits a standalone schema module importing nothing from it. - sdk: { run: sdkGenerator }, +// The sync registry for the `/generate` toolkit entry (which loads the emitters +// statically anyway). Compatibility metadata lives in BUILTIN_META — one home; +// only the eagerly imported `run` functions live here. The pipeline entry never +// touches this module: it loads built-ins lazily through the meta table. +const RUNS: Record> = { + typescript: { run: typescriptGenerator, sample: typescriptSample, docs: typescriptDocs }, zod: { run: zodGenerator }, - // transformers import the schema *types* from the sdk entry module (so sdk must run) and - // assign `Date` values to those fields, which only type-checks when the sdk types dates as `Date`. - transformers: { run: transformersGenerator, requires: ['sdk'], dateTypes: ['Date'] }, - // tanstack-query wraps the sdk's exported, throw-mode operation functions — present in - // both runtime distributions, so no runtime restriction. The framework variants differ - // only in the `@tanstack/-query` import; the bare name means React. - 'tanstack-query': tanstackQuery('react'), - 'tanstack-query-vue': tanstackQuery('vue'), - 'tanstack-query-svelte': tanstackQuery('svelte'), - 'tanstack-query-solid': tanstackQuery('solid'), - // swr wraps the sdk's exported, throw-mode operation functions as SWR hooks. - swr: { run: swrGenerator, requires: ['sdk'], errorModes: ['throw'] }, - // mock emits a standalone MSW handlers/factories module referencing the sdk's types. - mock: { run: mockGenerator, requires: ['sdk'] }, + transformers: { run: transformersGenerator }, + 'tanstack-query': { run: tanstackQueryGenerator('react') }, + 'tanstack-query-vue': { run: tanstackQueryGenerator('vue') }, + 'tanstack-query-svelte': { run: tanstackQueryGenerator('svelte') }, + 'tanstack-query-solid': { run: tanstackQueryGenerator('solid') }, + swr: { run: swrGenerator }, + mock: { run: mockGenerator }, + cli: { run: cliGenerator, sample: cliSample, docs: cliDocs }, + python: { run: pythonGenerator, sample: pythonSample, docs: pythonDocs }, + go: { run: goGenerator, sample: goSample, docs: goDocs }, + php: { run: phpGenerator, sample: phpSample, docs: phpDocs }, }; +const GENERATORS = Object.fromEntries( + (Object.entries(BUILTIN_META) as [GeneratorName, BuiltinMeta][]).map( + ([name, { load: _load, ...meta }]) => [name, { ...meta, ...RUNS[name] }] + ) +) as Record; + /** * A fresh registry of the built-in generators keyed by name. The plugin resolver seeds from this * and adds custom generators to the copy, so mutating the result never affects the built-in table. @@ -56,39 +62,8 @@ export function builtinGenerators(): Map { export function validateGenerators( names: string[], emit: EmitOptions, - registry: Map = builtinGenerators() + registry: Map = builtinGenerators(), + outputMode?: OutputMode ): void { - const selected = new Set(names); - 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) { - throw new NotSupportedError(`Unknown generator: ${name}`); - } - for (const required of descriptor.requires ?? []) { - if (!selected.has(required)) { - const fixed = [...new Set([required, ...names])].map((g) => `--generator ${g}`).join(' '); - throw new NotSupportedError( - `The "${name}" generator requires the "${required}" generator. Add it, e.g. ${fixed}.` - ); - } - } - if (descriptor.errorModes && !descriptor.errorModes.includes(errorMode)) { - throw new NotSupportedError( - `The "${name}" generator does not support --error-mode "${errorMode}" (supported: ${descriptor.errorModes.join(', ')}).` - ); - } - if (descriptor.dateTypes && !descriptor.dateTypes.includes(dateType)) { - throw new NotSupportedError( - `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(', ')}).` - ); - } - } + validateSelection(names, emit, registry, outputMode); } diff --git a/packages/client-generator/src/generators/java/AGENTS.md b/packages/client-generator/src/generators/java/AGENTS.md new file mode 100644 index 0000000000..afa7fc2a3f --- /dev/null +++ b/packages/client-generator/src/generators/java/AGENTS.md @@ -0,0 +1,77 @@ +# The `java` generator — its skill (DRAFT, design under review — no code exists yet) + +This file is the generator's DESIGN, written before any implementation (skill-first). +Once approved it ships to users on `redocly eject-generator java` (as the +`.claude/skills/java-generator/SKILL.md` agent skill) and governs all changes: **edit this skill first, then +make the code match it.** + +## What it emits + +A Java SDK from an OpenAPI description: typed models, a `Client` with one method per +operation, and the embedded runtime. **Java ≥ 17** (records, sealed interfaces, +switch patterns), HTTP over `java.net.http.HttpClient` — part of the JDK since 11. + +## ⚠ Open decisions for review (resolve before implementing) + +1. **JSON.** Java has NO stdlib JSON. Options: + - **(Recommended) Hand-written minimal JSON in the embedded runtime** — `Json.parse` + into a `Map/List/String/Double/Boolean/null` graph plus `Json.write`; ~300 lines, + verified like the other hand-written runtimes. Keeps the zero-dependency story + uniform with go/php. Limits (recorded honestly): no streaming parse; integral + numbers surface as `long`, fractions as `double`. + - Depend on Jackson — idiomatic and battle-tested, but the first generated SDK with a + runtime dependency, breaking the story users already know from go/php. +2. **File layout.** Java allows one public top-level class per file, so a single-file SDK + is impossible in the flat style the other languages use. Options: + - **(Recommended) Multi-file**: the generator emits a directory — + `/Client.java`, one file per model, `Runtime` support classes — under a + `package` derived from the API title (`com.example` configurable later). First + generator to use directory output; the pipeline already supports multiple files. + - Single file with everything nested inside one public class (`Api.Order`, + `Api.Client`) — keeps single-file symmetry but reads unidiomatic to Java teams. +3. **Errors.** Unchecked `ApiException extends RuntimeException` (recommended — checked + exceptions on every call poison lambdas/streams), carrying `status`, `url`, decoded + `body`; `TimeoutException` variant for exhausted attempts. + +## Design decisions (settled by precedent with the other languages) + +- **Models are records**: required components first; optionals as nullable boxed fields + (`Integer`, not `int`). Hydration is compile-time generated per record — + `static Order fromJson(Object json)` and `Object toJson()` over the runtime's JSON + graph, mirroring PHP's `fromArray`/`toArray` (no reflection). Wire names inline. +- **Every parameter is its own argument, so their names share one namespace** with the + arguments the method declares itself (`body`, `headers`, `options`). Build them with + `uniqueIdentifiers(..., { taken: … })`: OpenAPI lets one operation use a name in two + locations (`id` in the path AND in the query), and Java rejects a duplicate parameter. The + wire name is untouched, so the request is unchanged. +- **Naming:** classes PascalCase, fields/methods camelCase via + `identifierFor(..., RESERVED_WORDS.java)` (the `java` reserved set is new toolkit work); + `+1`/`-1` → `plus1`/`minus1`; digit-leading names get a letter prefix. +- **Enums** are Java enums with a `wire()` accessor and a `fromWire(String)` factory + (values like `in-progress` are not valid Java identifiers, so members are + SCREAMING_SNAKE with the wire literal attached). +- **Discriminated unions** are `sealed interface X permits A, B` with a generated + `static X parseX(Object json)` dispatcher on the discriminator property. Members + gain `implements X`. Undiscriminated unions surface as `Object`. +- **allOf** is flattened via `flattenAllOf`; `omit` uses the base record (readOnly + fields simply omitted from requests). +- **Client:** `new Client(Config config)`; per-op methods + `OrderPage listOrders(ListOrdersParams params)` throwing `ApiException`; params + objects are records with a builder (Java has no named arguments). +- **Parity surface** (same as python/go/php): auth (bearer/basic/apiKey), retries with + `Retry-After` + jittered backoff, per-attempt timeouts, idempotency keys, middleware + (`UnaryOperator`-style interceptors), pagination (`Iterable listOrdersPages()` + / `Iterable listOrdersItems()`), SSE (`Iterator` with + `Last-Event-ID` reconnect), multipart (hand-built body), `X-Redocly-Client` header. +- The runtime is hand-written in `java-runtime/` and embedded at prepare time; verified + with `javac` (and the smoke against the shared mock server). The large-description + suite gains a `javaBar` (`javac` on the big real-world outputs). CI runners ship a JDK. +- Authored ONLY with the neutral toolkit — the dogfooding guard extends to `java/index.ts`. + +## The modify loop (once implemented) + +1. Edit this skill: state the new behavior or decision. +2. Change `index.ts` (and `java-runtime/` for runtime behavior, then + `npm run prepare -w @redocly/client-generator`). +3. Verify: `npm run compile`, the generator unit suite (real `javac` bars), the e2e + smoke, and the large-description bars (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts new file mode 100644 index 0000000000..51bf2b328d --- /dev/null +++ b/packages/client-generator/src/generators/meta.ts @@ -0,0 +1,192 @@ +// Built-in generator METADATA — importable without loading any emitter (and so +// without loading `typescript`). The pipeline validates selections against this +// table and dynamic-imports only the generators actually selected; the sync +// `/generate` registry in index.ts derives from it, so the metadata has one home. + +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'; + +export type BuiltinMeta = Omit & { + load: () => Promise>; +}; + +function tanstackQuery(framework: 'react' | 'vue' | 'svelte' | 'solid'): BuiltinMeta { + return { + requires: ['typescript'], + errorModes: ['throw'], + load: () => + import('./tanstack-query/index.js').then((m) => ({ + run: m.tanstackQueryGenerator(framework), + })), + }; +} + +/** + * The TypeScript-only knobs a standalone language SDK cannot apply: it always emits + * one self-contained file with the runtime embedded, and each language passes inputs + * its own idiomatic way (keyword arguments, named arguments, a params struct). + */ +const LANGUAGE_SDK_NOT_APPLICABLE: BuiltinMeta['notApplicable'] = { + outputMode: 'it always emits one self-contained file', + runtime: 'the runtime is always embedded in the generated file', + argsStyle: "inputs follow the target language's own idiom", + importExt: 'the generated file has no relative imports', +}; + +export const BUILTIN_META: Record = { + // typescript is the base client; zod emits a standalone schema module importing nothing from it. + typescript: { + load: () => + import('./typescript/index.js').then((m) => ({ + run: m.typescriptGenerator, + sample: m.typescriptSample, + docs: m.typescriptDocs, + })), + }, + zod: { load: () => import('./zod/index.js').then((m) => ({ run: m.zodGenerator })) }, + // transformers import the schema *types* from the client entry module (so typescript must + // run) and assign `Date` values to those fields, which only type-checks when the client + // types dates as `Date`. + transformers: { + requires: ['typescript'], + dateTypes: ['Date'], + load: () => import('./transformers/index.js').then((m) => ({ run: m.transformersGenerator })), + }, + // tanstack-query wraps the client's exported, throw-mode operation functions — present in + // both runtime distributions, so no runtime restriction. The framework variants differ + // only in the `@tanstack/-query` import; the bare name means React. + 'tanstack-query': tanstackQuery('react'), + 'tanstack-query-vue': tanstackQuery('vue'), + 'tanstack-query-svelte': tanstackQuery('svelte'), + 'tanstack-query-solid': tanstackQuery('solid'), + // swr wraps the client's exported, throw-mode operation functions as SWR hooks. + swr: { + requires: ['typescript'], + errorModes: ['throw'], + load: () => import('./swr/index.js').then((m) => ({ run: m.swrGenerator })), + }, + // mock emits a standalone MSW handlers/factories module referencing the client's types. + mock: { + requires: ['typescript'], + load: () => import('./mock/index.js').then((m) => ({ run: m.mockGenerator })), + }, + // cli dispatches through the generated instance client and relies on thrown ApiError + // for its exit-code mapping, so it is bound to `typescript` and throw-only. + // Validation is part of the CLI's contract (exit code 3), so it requires `zod` — + // the pipeline pulls prerequisites in, so `--generator cli` alone is enough. + cli: { + requires: ['typescript', 'zod'], + errorModes: ['throw'], + load: () => + import('./cli/index.js').then((m) => ({ + run: m.cliGenerator, + sample: m.cliSample, + docs: m.cliDocs, + })), + }, + // python emits a standalone full Python SDK (httpx) — no TypeScript involved, + // so a python-only selection never loads the `typescript` package. + python: { + notApplicable: LANGUAGE_SDK_NOT_APPLICABLE, + load: () => + import('./python/index.js').then((m) => ({ + run: m.pythonGenerator, + sample: m.pythonSample, + docs: m.pythonDocs, + options: m.pythonOptions, + })), + }, + // go emits a standalone full Go SDK (stdlib-only) — no TypeScript involved. + // `(T, error)` returns ARE its error mode, so `result` has no Go rendering. + go: { + errorModes: ['throw'], + notApplicable: LANGUAGE_SDK_NOT_APPLICABLE, + load: () => + import('./go/index.js').then((m) => ({ + run: m.goGenerator, + sample: m.goSample, + docs: m.goDocs, + })), + }, + // php emits a standalone full PHP SDK (curl extension) — no TypeScript involved. + // Exceptions ARE its error mode, so `result` has no PHP rendering. + php: { + errorModes: ['throw'], + notApplicable: LANGUAGE_SDK_NOT_APPLICABLE, + load: () => + import('./php/index.js').then((m) => ({ + run: m.phpGenerator, + sample: m.phpSample, + docs: m.phpDocs, + })), + }, +}; + +/** + * Validate a generator selection against every selected generator's declared + * contract, throwing the first violation with an actionable message. Runs before + * any file is produced so an incompatible combination never reaches the printer. + * Works on metadata alone — the `run` field is never touched. + */ +export function validateSelection( + names: string[], + emit: EmitOptions, + registry: Map | GeneratorDescriptor>, + // `outputMode` travels beside `emit` in the generator input, so the caller passes it + // in for the not-applicable check; absent means the caller left it at the default. + outputMode?: OutputMode +): void { + const selected = new Set(names); + // `goPackage` is read by one generator, which `notApplicable` can't express: it fires + // per generator, so marking the option on `typescript` would warn on `--generator + // typescript --generator go`, where `go` does apply it. Setting it with `go` unselected + // does nothing at all, which is worth saying. + if (emit.goPackage !== undefined && !selected.has('go')) { + logger.warn( + 'generate-client: goPackage is ignored — it declares the Go package clause, and no selected generator uses it (add --generator go).\n' + ); + } + 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) { + throw new NotSupportedError(`Unknown generator: ${name}`); + } + for (const required of descriptor.requires ?? []) { + if (!selected.has(required)) { + const fixed = [...new Set([required, ...names])].map((g) => `--generator ${g}`).join(' '); + throw new NotSupportedError( + `The "${name}" generator requires the "${required}" generator. Add it, e.g. ${fixed}.` + ); + } + } + if (descriptor.errorModes && !descriptor.errorModes.includes(errorMode)) { + throw new NotSupportedError( + `The "${name}" generator does not support --error-mode "${errorMode}" (supported: ${descriptor.errorModes.join(', ')}).` + ); + } + if (descriptor.dateTypes && !descriptor.dateTypes.includes(dateType)) { + throw new NotSupportedError( + `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 }; + for (const [option, reason] of Object.entries(descriptor.notApplicable ?? {})) { + if (chosen[option] !== undefined) { + logger.warn(`generate-client: the "${name}" generator ignores ${option} — ${reason}.\n`); + } + } + } +} diff --git a/packages/client-generator/src/generators/mock/AGENTS.md b/packages/client-generator/src/generators/mock/AGENTS.md new file mode 100644 index 0000000000..a6a50fd539 --- /dev/null +++ b/packages/client-generator/src/generators/mock/AGENTS.md @@ -0,0 +1,42 @@ +# The `mock` generator — its skill + +This file is the generator's DESIGN and governs our own changes: **to change the +generator, edit this skill first, then make the code match it.** + +`npm run prepare` compiles it into `eject-assets/skills/mock-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + +## What it emits + +A standalone MSW module: `create()` data factories, `Handler()` / +`ErrorHandler(status, body?)` request handlers, and a `handlers` array. + +## Design decisions that must hold + +- **Two data modes:** `mockData: static` bakes deterministic samples from the schema + (examples/defaults first); `faker` emits `faker.*` calls with a seed (`mockSeed`) so + runs are reproducible. +- **Interpolated identifiers are gated** (`codeIdent`): an operation name or method + reaching a code position is validated, never trusted, even though the pipeline + sanitizes upstream. +- Handlers are opt-in overrides: `ErrorHandler` is NOT in `handlers`. +- The module references the sdk's TYPES only — never its runtime. + +## Emitters that implement it + +`emitters/mock.ts`, `mock-value.ts` (data trees), `faker.ts`, `sample.ts`. + +## Ejecting it + +`redocly eject-generator mock` ships this generator BUNDLED with the emitter it uses — one +small `.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. +Change the data strategy, the handler shape, or the factory surface, and regenerate. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Change the emitter modules named above (the entry is plumbing — it rarely moves). +3. Verify: `npm run compile`, the emitter unit suites + (`VITEST_SUITE=unit npx vitest run packages/client-generator/src/emitters`), the e2e + suites for this generator, and the large-description bars + (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/mock.ts b/packages/client-generator/src/generators/mock/index.ts similarity index 79% rename from packages/client-generator/src/generators/mock.ts rename to packages/client-generator/src/generators/mock/index.ts index 1f358625f7..f139fff4ec 100644 --- a/packages/client-generator/src/generators/mock.ts +++ b/packages/client-generator/src/generators/mock/index.ts @@ -1,9 +1,9 @@ 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 { HEADER } from '../../emitters/emit-options.js'; +import { renderMockModule } from '../../emitters/mock.js'; +import { anchor } from '../anchor.js'; +import type { Generator } from '../types.js'; /** * The mock generator: a standalone `.mocks.ts` module of MSW handlers and diff --git a/packages/client-generator/src/generators/options.ts b/packages/client-generator/src/generators/options.ts new file mode 100644 index 0000000000..1863b630bb --- /dev/null +++ b/packages/client-generator/src/generators/options.ts @@ -0,0 +1,96 @@ +// Per-generator options (`client.options.`), validated against the schema each +// generator declares. Validation runs once per run, before any file is written, so a +// typo in the config fails with the generator name and the offending key instead of +// reaching `run` — and a generator reads its options without re-checking them. + +import { isPlainObject, logger } from '@redocly/openapi-core'; + +import { NotSupportedError } from '../errors.js'; +import type { + GeneratorDescriptor, + GeneratorOptionSchema, + GeneratorOptionsSchema, +} from './types.js'; + +/** + * The validated options for every selected generator, keyed by name. Entries for + * generators this run didn't select are ignored: one config may serve several runs. + */ +export function resolveGeneratorOptions( + names: string[], + registry: Map | GeneratorDescriptor>, + configured: Record> | undefined +): Map> { + const resolved = new Map>(); + for (const name of names) { + const schema = registry.get(name)?.options; + const values = configured?.[name]; + if (schema === undefined) { + if (values !== undefined) { + logger.warn( + `generate-client: the "${name}" generator declares no options, so client.options.${name} is ignored.\n` + ); + } + resolved.set(name, {}); + continue; + } + resolved.set(name, validate(name, schema, values)); + } + return resolved; +} + +function validate( + name: string, + schema: GeneratorOptionsSchema, + values: Record | undefined +): Record { + if (values !== undefined && !isPlainObject(values)) { + throw new NotSupportedError( + `The "${name}" generator's options must be a map of option names to values.` + ); + } + const given = values ?? {}; + const declared = Object.keys(schema.properties); + if (schema.additionalProperties !== true) { + for (const key of Object.keys(given)) { + if (!declared.includes(key)) { + throw new NotSupportedError( + `The "${name}" generator got an unknown option "${key}". Declared options: ${declared.join(', ') || '(none)'}.` + ); + } + } + } + for (const key of schema.required ?? []) { + if (given[key] === undefined) { + throw new NotSupportedError(`The "${name}" generator requires the "${key}" option.`); + } + } + const result: Record = { ...given }; + for (const [key, property] of Object.entries(schema.properties)) { + if (result[key] === undefined) { + if (property.default !== undefined) result[key] = property.default; + continue; + } + const problem = describeMismatch(property, result[key]); + if (problem !== undefined) { + throw new NotSupportedError(`The "${name}" generator's "${key}" ${problem}.`); + } + } + return result; +} + +/** How a value fails its option schema, phrased to complete `"" …`; undefined when it fits. */ +function describeMismatch(property: GeneratorOptionSchema, value: unknown): string | undefined { + if ('enum' in property) { + return property.enum.includes(value as string | number | boolean) + ? undefined + : `must be one of: ${property.enum.join(', ')}`; + } + if (property.type === 'array') { + const itemType = property.items.type; + return Array.isArray(value) && value.every((item) => typeof item === itemType) + ? undefined + : `must be an array of ${itemType}`; + } + return typeof value === property.type ? undefined : `must be a ${property.type}`; +} diff --git a/packages/client-generator/src/generators/php/AGENTS.md b/packages/client-generator/src/generators/php/AGENTS.md new file mode 100644 index 0000000000..84c69dc9d4 --- /dev/null +++ b/packages/client-generator/src/generators/php/AGENTS.md @@ -0,0 +1,110 @@ +# The `php` generator — its skill + +This file is the generator's DESIGN. It ships to users on `redocly eject-generator php` +(as the `.claude/skills/php-generator/SKILL.md` agent skill) and governs our own changes: **to change the generator, +edit this skill first, then make the code match it** — a diff to `index.ts` that has no +covering sentence here is incomplete. + +`npm run prepare` compiles it into `eject-assets/skills/php-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + +## What it emits + +One self-contained `.php`: promoted-constructor model classes, a `Client` with one +typed method per operation, and the embedded runtime. PHP ≥ 8.1, HTTP over the curl +extension — zero Composer dependencies. The namespace derives from the API title +(`identifierFor(title, pascal)` — e.g. `CafeOrders`). + +## Design decisions that must hold + +- **Models are `final class`es** with constructor property promotion, required parameters + first, optionals nullable `= null`. Hydration is compile-time generated per class: + `fromArray(array $data): self` and `toArray(): array` (wire names inline; nulls + skipped on serialize) — no reflection. `omit` schemas hydrate/serialize through their + base class. A property or response typed as a DISCRIMINATED union hydrates through the + union's `unmarshalX` dispatcher, so consumers can narrow with `instanceof`; + undiscriminated unions stay raw arrays. +- The `Client` class is NOT `final` — PHP test suites mock concrete classes + (`createMock(Client::class)`), and `final` would force a wrapper interface on every + consumer. Model classes stay `final`. +- **Every parameter is its own argument, so their names share one namespace** with the + arguments the method declares itself (`$body`, `$headers`, `$idempotencyKey`). Build them with + `uniqueIdentifiers(..., { taken: … })`: OpenAPI lets one operation use a name in two + locations (`id` in the path AND in the query), and PHP rejects a redefined parameter outright. The + wire name is untouched, so the request is unchanged. +- **Naming:** classes PascalCase, properties/methods camelCase via + `identifierFor(..., RESERVED_WORDS.php)`; reserved words get a trailing underscore. +- **Enums** are native backed enums (string/int); other scalars stay aliases. + **Discriminated unions** are `match`-based `unmarshalX(array $data)` dispatchers; + **allOf** is flattened. +- **Unions keep their types where PHP 8.1 can express them.** A union of scalars, enums, + classes, or arrays becomes a native union type (`int|string`, `PromotionType|array`) + rather than collapsing to `mixed` — rich list filters are the common case and losing + their types loses the point of a typed SDK. It falls back to `mixed` only when a member + has no PHP type of its own (an inline object, an intersection, `unknown`), because + `mixed` cannot appear inside a union. Nullability is expressed as `|null` in a union + (PHP forbids mixing `?` with `|`) and `?T` for a single type. +- **Errors:** exceptions ARE the error mode (`ApiError`/`TimeoutError` extend + `\RuntimeException`); `errorMode` does not change the output (the generator declares + `errorModes: ['throw']`, so `result` fails fast). +- **Dates:** `dateType: Date` types `format: date`/`date-time` as + `\DateTimeImmutable`; hydration is `new \DateTimeImmutable(...)` and serialization + formats with `\DateTimeInterface::ATOM` (date-time) or `'Y-m-d'` (date), including + for query parameters. +- **Method arguments:** required path params positional, JSON body next, optional query + params as nullable NAMED arguments, then `?array $headers`, and `?string +$idempotencyKey` on mutating methods. +- **Non-JSON success bodies** (PDFs, images, octet streams) return the raw body as + `string` — a binary download must never degrade to `void`. +- **PHPDoc carries what the signature cannot.** PHP's `array` and `\Generator` erase their + element type, so a docblock states it: `@return Customer[]` for collection returns and + `@return \Generator` on `Pages()`/`Items()`. Static analysis and + readers go by these; a hydrated return with no annotation looks untyped. +- **Response headers:** an operation that DECLARES success-response headers gains a + `WithHeaders()` variant returning an `Envelope` (`data`, `headers` — coerced to + int/bool/string with camelCase keys, absent/unparsable values omitted — and `status`). + Operations without declared headers get no variant, and the base method stays + body-only (PHP cannot vary a return type on a flag). +- **Servers:** when the description declares servers, a `Servers` class is emitted with + one static method per server; server VARIABLES become named string arguments defaulting + to the spec's defaults (`Servers::production(organizationId: 'org_x')`), so templated + base URLs need no manual string building. The client's baked default stays `servers[0]` + with variable defaults substituted. +- **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 + at prepare time. `curl_close` is never called (deprecated since PHP 8.5, no-op since 8.0). +- Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. + +## Migrating from a service-based SDK + +- Per-resource services (`$client->customers()->get($id)`) map to flat methods named + after operationIds (`$client->getCustomer($id)`); optional query params keep their + named-argument style (`filter:`, `sort:`, `limit:`). +- Collection wrappers exposing pagination RESPONSE HEADERS (`getTotalItems()`, + `getLimit()`) map to the `WithHeaders()` envelope + (`->headers['paginationTotal']`); plain iteration maps to `Items()` / + `Pages()` generators. +- Dedicated validation-exception classes exposing field errors map to + `catch (ApiError $e)` + `$e->status === 422` + the decoded `$e->body`. +- Session/bearer token flows map to `auth: ['bearer' => $tokenProvider]` with a + callable — resolved per request, so refresh needs no client rebuild. + +- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes + `.php.md`: the security schemes, then one section per operation with its parameters, + body, response type, and behavior notes. The call snippets come from this generator's own + `sample` hook, so the page can only show the syntax of the SDK beside it, and the layout + comes from `renderReferencePage` in the authoring toolkit — reachable from an ejected copy + through `@redocly/client-generator`. Pagination on the page is decided by + `paginationRuleFor`, the same helper this generator resolves pagination with. + +## 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, + 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` + (real `php -l` + `require` bars), the e2e smoke (`tests/e2e/generate-client/php.test.ts`), + and the large-description bars (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts new file mode 100644 index 0000000000..f6273fb1e5 --- /dev/null +++ b/packages/client-generator/src/generators/php/index.ts @@ -0,0 +1,1091 @@ +// The built-in `php` generator — the third non-TypeScript library entry, authored +// with the language-neutral toolkit only (same dogfooding invariant as python/go, +// pinned by the guard test). Output is a single PHP >= 8.1 file over the curl +// extension: promoted-constructor classes with fromArray/toArray hydration, native +// backed enums, match-based discriminator dispatchers, and a Client over the +// embedded runtime. Exceptions are the error mode (`errorMode` does not apply). + +import { + Printer, + docText, + discriminatorCases, + enumValues, + flattenAllOf, + headerCoerceType, + identifierFor, + uniqueIdentifiers, + isNullable, + paginationRuleFor, + renderReferencePage, + RESERVED_WORDS, + schemaAtPointer, + unwrapNullable, + 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(); +} + +/** Drop the standalone header ( { + const printer = new Printer(' '); + const dateType = emit.dateType ?? 'string'; + const namespace = identifierFor(model.title, { style: 'pascal', reserved: PHP }); + printer.line('= 8.1, curl extension — zero Composer dependencies.' + ); + printer.blank(); + printer.line('declare(strict_types=1);'); + printer.blank(); + printer.line(`namespace ${namespace};`); + 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(); + + const operations = model.services.flatMap((service) => service.operations); + 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); + } + + printer.block( + 'const OPERATIONS = [', + () => { + for (const op of operations) { + const id = op.specName ?? op.name; + const security = phpSecurityLiteral(op, model); + const rule = paginationRules.get(op.name); + const fields = [ + `'id' => ${phpString(id)}`, + `'method' => ${phpString(op.method.toUpperCase())}`, + `'path' => ${phpString(op.path)}`, + ...(security !== undefined ? [`'security' => ${security}`] : []), + ...(rule !== undefined ? [`'pagination' => ${phpPaginationLiteral(rule)}`] : []), + ]; + printer.line(`${phpString(id)} => [${fields.join(', ')}],`); + } + }, + '];' + ); + printer.blank(); + + writeDocComment(printer, 'Client', `Client for ${model.title} (${model.version}).`); + // Not final: PHP test suites mock concrete classes (createMock(Client::class)). + printer.line('class Client'); + printer.block( + '{', + () => { + printer.line('public function __construct(private Config $config)'); + printer.block( + '{', + () => { + printer.block( + "if ($this->config->serverUrl === '') {", + () => { + printer.line( + `$this->config->serverUrl = ${phpString(emit.serverUrl ?? model.serverUrl ?? '')};` + ); + }, + '}' + ); + }, + '}' + ); + printer.blank(); + + for (const op of operations) { + writePhpMethod(printer, op, model, dateType); + if (sseResponse(op) === undefined && (op.successResponseHeaders?.length ?? 0) > 0) { + writePhpMethod(printer, op, model, dateType, true); + } + const rule = paginationRules.get(op.name); + if (rule === undefined) continue; + const success = successSchema(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 itemHydration = + element === undefined ? undefined : hydration(element, '$item', model, dateType); + writePhpPaginationWrappers( + printer, + op, + model, + dateType, + pageHydration, + itemHydration, + rule.items, + element === undefined ? 'mixed' : phpType(element, model, dateType) + ); + } + }, + '}' + ); + + return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.php'), content: printer.toString() }]; +}; + +/** One idiomatic PHP call per operation — feeds `x-codeSamples` for docs. */ +export function phpSample(op: OperationModel, ctx: SampleContext): CodeSample { + const args = [ + ...op.pathParams.map((param) => `${phpString(`<${propertyName(param.name)}>`)}`), + ...(op.requestBody ? ['$body'] : []), + ...(op.queryParams.length > 0 + ? [`${propertyName(op.queryParams[0].name)}: ${phpString('')}`] + : []), + ]; + const namespace = identifierFor(ctx.model.title, { style: 'pascal', reserved: PHP }); + // The file this run writes, so the snippet requires something that exists. + const file = ctx.outputPath.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '.php'); + return { + lang: 'php', + label: 'PHP SDK', + source: `require '${file}';\n\nuse ${namespace}\\{Client, Config};\n\n$client = new Client(new Config());\n$result = $client->${methodName(op)}(${args.join(', ')});\n`, + }; +} + +/** + * The SDK's own reference page, written when `client.docs` is on. The call snippets come + * 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 }) => [ + { + path: outputPath.replace(/\.[^.\\/]+$/, '.php.md'), + content: renderReferencePage(model, { + title: `${model.title} PHP SDK reference`, + frontmatter: emit.docsFrontmatter === true, + language: { + name: 'php', + label: 'PHP', + fence: 'php', + requires: 'The SDK needs the curl extension.', + }, + sample: (op) => phpSample(op, { model, emit, outputPath }), + pagination: emit.pagination, + }), + }, +]; diff --git a/packages/client-generator/src/generators/python/AGENTS.md b/packages/client-generator/src/generators/python/AGENTS.md new file mode 100644 index 0000000000..308cdaa5cf --- /dev/null +++ b/packages/client-generator/src/generators/python/AGENTS.md @@ -0,0 +1,108 @@ +# The `python` generator — its skill + +This file is the generator's DESIGN. It ships to users on `redocly eject-generator python` +(as the `.claude/skills/python-generator/SKILL.md` agent skill) and governs our own changes: **to change the generator, +edit this skill first, then make the code match it** — a diff to `index.ts` that has no +covering sentence here is incomplete. + +`npm run prepare` compiles it into `eject-assets/skills/python-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + +## What it emits + +One self-contained `.py`: typed dataclass models, a sync `Client` and an async +`AsyncClient`, and the embedded runtime. Python ≥ 3.9; the only dependency is +[httpx](https://www.python-httpx.org/) (`pip install httpx`). + +## Design decisions that must hold + +- **The file name is an importable module name.** The `--output` stem follows the TypeScript + convention (`openapi.client.ts`), and `openapi.client.py` cannot be imported by name — nor + can hyphens or a leading digit. The stem is converted with + `identifierFor(stem, snake)`, so `rebilly-core.client.ts` emits + `rebilly_core_client.py` and `import rebilly_core_client` just works. + +- **Models are dataclasses by default**, required fields first (a dataclass constraint), + optionals `Optional[T] = None`. Wire names live in a `_field_map: ClassVar[Dict[str, str]]`; + decode/encode is reflective (`_decode.py`, `get_type_hints`) — no per-model codecs. +- **`models: pydantic` emits `BaseModel` classes instead**, for the FastAPI-shaped half of + the ecosystem that expects them. A wire name becomes `Field(alias=…)` with + `populate_by_name=True`, so `_field_map` is not emitted in this mode — the alias is the + mapping. Everything else is unchanged: the same class names, the same field names, the + same `Optional[T] = None`, the same enums and union aliases, the same client and runtime. + Switching modes must not change a call site. +- **A discriminated union carries its discriminator into the pydantic annotation.** 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 that dataclass mode + walks. Pydantic resolves it correctly from `Annotated[Union[...], Field(discriminator=…)]`, + which it accepts only when every member types that property as a `Literal` — and the + mapping already pins one value per member, so the members get `Literal["cat"]`. Such a + union registers no table entry: pydantic owns it at every depth, and the `Literal` makes + the decoder's member probe exact. A union whose members never declare the property keeps + the plain `Union` and the table entry, and pydantic then matches nested members its own + way — the description is what has to change there. +- **One runtime serves both model modes.** `_decode.py` dispatches on the target: a class + with `model_validate` is validated by pydantic, a dataclass is hydrated reflectively, and + `encode` mirrors that with `model_dump(by_alias=True, exclude_none=True, mode="json")`. + A second runtime variant per mode would double the surface that has to stay in step, and + pydantic's `ValidationError` already subclasses `ValueError`, so union member probing + needs no new except clause. +- **`models: pydantic` adds a dependency, and the header says so.** The default mode keeps + httpx as the only requirement; the pydantic header asks for both. A mode that quietly + needed a package the file never named would fail at import with nothing to act on. +- **Every parameter is its own argument, so their names share one namespace** with the + arguments the method declares itself (`body`, `headers`, `timeout`, `retry`, `idempotency_key`). Build them with + `uniqueIdentifiers(..., { taken: … })`: OpenAPI lets one operation use a name in two + locations (`id` in the path AND in the query), and a `def` that declared one name twice is a `SyntaxError`. The + wire name is untouched, so the request is unchanged. +- **Naming:** fields/methods snake*case via `identifierFor(..., RESERVED_WORDS.python)`; + reserved words get a trailing underscore (`class*`); `+1`/`-1`become`plus_1`/`minus_1`. +- **Enums** are `class X(str, Enum)` with SCREAMING members; **unions** are `Union[...]` + aliases. A DISCRIMINATED union registers its dispatch table in the runtime's + `DISCRIMINATORS` registry (`DISCRIMINATORS[Pet] = ("petType", {"cat": Cat, ...})`), + and `decode()` routes through it — `isinstance` narrowing works on decoded members. + Undiscriminated unions decode by trying each member in order (the first that + hydrates wins — see `_decode.py`). **allOf** is flattened via `flattenAllOf`. +- **Auth keys match the other languages.** `auth={"apiKey": {...}}` is the documented key — + the same spelling TypeScript and PHP use, and the same as the scheme kind — with + `api_key` accepted as an alias so a snake_case config keeps working. +- **Errors:** `errorMode` maps to raising `ApiError` (default) or returning a `Result` + dataclass — the only generator with both modes outside TypeScript. +- **Dates:** `dateType: Date` annotates `format: date-time` as `datetime` and `date` as + `date`; `_decode.py` parses ISO strings into them and `encode()` writes `isoformat()` + back. The default (`string`) keeps the wire shape. +- **Response headers:** an operation that DECLARES success-response headers gains a + `_with_headers()` variant (sync and async) returning `Envelope[T]` — `data`, + `headers` (coerced to int/bool/str with snake_case keys; absent/unparsable values + omitted), and the raw `response`. Operations without declared headers get no + variant, and the base method stays body-only. +- **Servers:** when the description declares servers, a `Servers` class is emitted with + one static method per server; server VARIABLES become keyword arguments defaulting to + the spec's defaults (`Servers.production(organization_id="org_x")`), so templated base + URLs need no manual string building. The client's baked default stays `servers[0]` + with variable defaults substituted. +- **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 + time — generator code never builds runtime logic from templates. +- Authored ONLY with the neutral toolkit (`Printer`, naming, schema, pagination helpers) — + the dogfooding guard fails otherwise. + +- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes + `.python.md`: the security schemes, then one section per operation with its parameters, + body, response type, and behavior notes. The call snippets come from this generator's own + `sample` hook, so the page can only show the syntax of the SDK beside it, and the layout + comes from `renderReferencePage` in the authoring toolkit — reachable from an ejected copy + through `@redocly/client-generator`. Pagination on the page is decided by + `paginationRuleFor`, the same helper this generator resolves pagination with. + +## 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 + `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` + (real `py_compile` bars), the e2e smoke (`tests/e2e/generate-client/python.test.ts`), + and the large-description bars (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts new file mode 100644 index 0000000000..20cb9e99fc --- /dev/null +++ b/packages/client-generator/src/generators/python/index.ts @@ -0,0 +1,952 @@ +// The built-in `python` generator — the first non-TypeScript library entry, +// 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. + +import { + Printer, + paginationRuleFor, + renderReferencePage, + schemaAtPointer, + discriminatorCases, + docText, + enumValues, + flattenAllOf, + headerCoerceType, + 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('"""'); +} + +/** The model style the generator emits: plain dataclasses, or pydantic `BaseModel`s. */ +export type PythonModels = 'dataclass' | 'pydantic'; + +export const pythonOptions: GeneratorOptionsSchema = { + type: 'object', + properties: { + models: { + enum: ['dataclass', 'pydantic'], + default: 'dataclass', + description: + 'Model style: standard-library dataclasses (default, httpx is the only dependency), or pydantic BaseModel classes (adds pydantic).', + }, + }, + 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 + * imported by name — nor can a hyphen or a leading digit — so the stem is converted + * to a legal module identifier (`openapi_client.py`). The directory is untouched. + */ +function pythonModulePath(outputPath: string): string { + const separator = outputPath.lastIndexOf('/') >= 0 ? '/' : '\\'; + const cut = outputPath.lastIndexOf(separator); + const dir = cut >= 0 ? outputPath.slice(0, cut + 1) : ''; + const stem = (cut >= 0 ? outputPath.slice(cut + 1) : outputPath).replace(/\.[^.]+$/, ''); + return `${dir}${identifierFor(stem, { style: 'snake', reserved: PY })}.py`; +} + +/** The whole generated file: header, models, embedded runtime, descriptors, clients. */ +export const pythonGenerator: Generator = ({ model, outputPath, emit, options }) => { + 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(' '); + printer.line( + `# Generated by @redocly/client-generator (python) from "${model.title}" ${model.version}.` + ); + printer.line('# Do not edit by hand — regenerate with `redocly generate-client`.'); + printer.line( + models === 'pydantic' + ? '# Requires Python >= 3.9, httpx, and pydantic: pip install httpx pydantic' + : '# Requires Python >= 3.9 and httpx: pip install httpx' + ); + printer.blank(); + + // Models (with the shared imports header). + printer.line(renderPythonModels(model, dateType, models).trimEnd()); + printer.blank(); + 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); + 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().'); + for (const registration of registrations) printer.line(registration); + printer.blank(); + } + printer.block('def _safe_json(response: httpx.Response) -> Any:', () => { + printer.block('try:', () => { + printer.line('return response.json()'); + }); + printer.block('except Exception:', () => { + printer.line('return None'); + }); + }); + printer.blank(); + + // 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 }) + ); + } + printer.line('_OPERATIONS = {'); + printer.indent(() => { + for (const { op, ident } of operationIdents(model)) { + const descriptor = { + id: op.specName ?? op.name, + method: op.method.toUpperCase(), + path: op.path, + ...(securitySpecs(op, model).length > 0 ? { security: securitySpecs(op, model) } : {}), + ...(paginationSpecs.get(ident) !== undefined + ? { pagination: paginationSpecs.get(ident) } + : {}), + }; + printer.line(`"${ident}": ${pythonLiteral(descriptor)},`); + } + }); + printer.line('}'); + printer.blank(); + printer.blank(); + + // The `serverUrl` option overrides the description's server, like the TS sdk. + const serverUrl = emit.serverUrl ?? model.serverUrl ?? ''; + writeClientClass(printer, model, errorMode, false, paginationSpecs, serverUrl, dateType); + writeClientClass(printer, model, errorMode, true, paginationSpecs, serverUrl, dateType); + + return [{ path: pythonModulePath(outputPath), content: printer.toString() }]; +}; + +/** One idiomatic Python call per operation — feeds `x-codeSamples` for docs. */ +export function pythonSample(op: OperationModel, ctx: SampleContext): CodeSample { + // The module name this run writes, not a guess: `openapi.client.ts` becomes + // `openapi_client.py`, so `from client import Client` would not import. + const module = pythonModulePath(ctx.outputPath) + .replace(/^.*[\\/]/, '') + .replace(/\.py$/, ''); + const ident = identifierFor(op.name, { style: 'snake', reserved: PY }); + const args = [ + ...op.pathParams.map((param) => { + const python = identifierFor(param.name, { style: 'snake', reserved: PY }); + return `${python}="<${python}>"`; + }), + ...op.queryParams + .filter((param) => param.required) + .map((param) => { + const python = identifierFor(param.name, { style: 'snake', reserved: PY }); + return `${python}=...`; + }), + ...(op.requestBody ? ['body=...'] : []), + ]; + return { + lang: 'python', + label: 'Python SDK', + source: `from ${module} import Client\n\nclient = Client()\nresult = client.${ident}(${args.join(', ')})\n`, + }; +} + +/** + * The SDK's own reference page, written when `client.docs` is on. The call snippets come + * 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 }) => [ + { + path: outputPath.replace(/\.[^.\\/]+$/, '.python.md'), + content: renderReferencePage(model, { + title: `${model.title} Python SDK reference`, + frontmatter: emit.docsFrontmatter === true, + language: { + name: 'python', + label: 'Python', + fence: 'python', + requires: 'The SDK needs `httpx`.', + }, + sample: (op) => pythonSample(op, { model, emit, outputPath }), + pagination: emit.pagination, + }), + }, +]; diff --git a/packages/client-generator/src/generators/resolve.ts b/packages/client-generator/src/generators/resolve.ts index 509fe72514..36161b139e 100644 --- a/packages/client-generator/src/generators/resolve.ts +++ b/packages/client-generator/src/generators/resolve.ts @@ -5,12 +5,13 @@ // default (or `generator`) export validated, and registered under its declared name. Built-ins are // seeded fresh per call (see `builtinGenerators`), so registration never mutates the built-in table. -import { isAbsoluteUrl, isPlainObject } from '@redocly/openapi-core'; +import { isAbsoluteUrl, isPlainObject, logger } from '@redocly/openapi-core'; import { isAbsolute, resolve as resolvePath } from 'node:path'; import { pathToFileURL } from 'node:url'; import { NotSupportedError } from '../errors.js'; -import { builtinGenerators } from './index.js'; +import { GENERATOR_VERSION, satisfiesGeneratorRange } from './compatibility.js'; +import { BUILTIN_META, type BuiltinMeta } from './meta.js'; import type { CustomGenerator, GeneratorDescriptor } from './types.js'; export type ResolvedGenerators = { @@ -35,22 +36,69 @@ export async function resolveGenerators( entries: string[], options: ResolveOptions = {} ): Promise { - const registry = builtinGenerators(); + // Built-ins are loaded lazily through BUILTIN_META so a selection without a + // TypeScript-emitting generator never loads the `typescript` package. + const registry = new Map(); for (const custom of options.customGenerators ?? []) register(registry, custom); - const selected: string[] = []; + // Load every selected entry first: an import specifier's declared name and `requires` + // are only known once it is imported, and an ejected generator carries the same + // `requires` as the built-in it replaces. + const names: string[] = []; for (const entry of entries) { - if (registry.has(entry)) { - selected.push(entry); - continue; - } - const custom = await importGenerator(entry, options.configDir ?? process.cwd()); - register(registry, custom); - selected.push(custom.name); + names.push(await loadEntry(entry, registry, options.configDir)); } + + // A prerequisite is pulled in rather than demanded: selecting `cli` should give a + // working CLI without the user knowing which other generators provide its parts. + const selected: string[] = []; + const visiting = new Set(); + const add = async (name: string): Promise => { + if (selected.includes(name) || visiting.has(name)) return; + visiting.add(name); + for (const required of registry.get(name)!.requires ?? []) { + // Only pull in a prerequisite we know how to load — an already-registered + // generator or a built-in. Anything else stays the user's problem and is + // reported by `validateSelection`. + if (registry.has(required) || required in BUILTIN_META) { + await add(await loadEntry(required, registry, options.configDir)); + } + } + visiting.delete(name); + selected.push(name); + }; + for (const name of names) await add(name); return { selected, registry }; } +/** + * Load one entry — an already-registered name, a built-in name, or an import specifier — + * into the registry, and return the name it is registered under. + */ +async function loadEntry( + entry: string, + registry: Map, + configDir?: string +): Promise { + if (registry.has(entry)) return entry; + const meta = (BUILTIN_META as Record)[entry]; + if (meta !== undefined) { + const { load, ...compatibility } = meta; + registry.set(entry, { ...compatibility, ...(await load()) }); + return entry; + } + // Without this, the old name falls through to `import('sdk')` and fails with a + // module-load error that hides the rename. + if (entry === 'sdk') { + throw new NotSupportedError( + 'The "sdk" generator is now named "typescript". Update the `generators` list or the --generator flag.' + ); + } + const custom = await importGenerator(entry, configDir ?? process.cwd()); + register(registry, custom); + return custom.name; +} + /** Validate a custom generator and add it under its name, rejecting collisions. */ function register(registry: Map, custom: CustomGenerator): void { if ( @@ -68,8 +116,33 @@ function register(registry: Map, custom: CustomGene `Generator name "${custom.name}" collides with an existing generator. Rename the custom generator.` ); } + // The model and the helpers change under semver, so a declared range that excludes the + // running version means the generator and this CLI disagree on the contract. + if (custom.requiresGenerator !== undefined) { + const satisfied = satisfiesGeneratorRange(GENERATOR_VERSION, custom.requiresGenerator); + if (satisfied === undefined) { + throw new NotSupportedError( + `Generator "${custom.name}" declares requiresGenerator "${custom.requiresGenerator}", which is not a range we read. Use ^1.2.0, ~1.2.0, >=1.2.0, or an exact version.` + ); + } + if (!satisfied) { + throw new NotSupportedError( + `Generator "${custom.name}" needs @redocly/client-generator ${custom.requiresGenerator}; this CLI ships ${GENERATOR_VERSION}. ` + + 'Upgrade @redocly/cli if the generator is newer, or update the generator — `redocly eject-generator --update` for an ejected file, or upgrade its package.' + ); + } + } + // A custom generator MAY take over a built-in name — that's how an ejected + // generator replaces its origin without a config rename. Announce the takeover. + if (custom.name in BUILTIN_META) { + logger.warn( + `generate-client: custom generator "${custom.name}" takes over the built-in generator of the same name.\n` + ); + } registry.set(custom.name, { run: custom.run, + sample: custom.sample, + options: custom.options, requires: custom.requires, errorModes: custom.errorModes, dateTypes: custom.dateTypes, diff --git a/packages/client-generator/src/generators/sdk.ts b/packages/client-generator/src/generators/sdk.ts deleted file mode 100644 index 8e60e0a329..0000000000 --- a/packages/client-generator/src/generators/sdk.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { join } from 'node:path'; - -import { emitClientSingleFile, emitClientSplit } from '../emitters/client-assembly.js'; -import { anchor } from './anchor.js'; -import type { Generator } from './types.js'; - -/** - * The default generator: the full typed client (model types + runtime + endpoints). - * Other generators (zod, framework hooks) emit *additional* files alongside. - * - * `single` mode writes the whole client to the `--output` path. `split` mode derives - * two sibling files from that anchor — `.schemas.ts` (model types, enums, - * const-objects, type guards; skipped when the document declares no schemas) and - * `.ts` (everything else, which `export *`s the schemas module). - */ -export const sdkGenerator: Generator = ({ model, outputPath, outputMode, emit }) => { - if (outputMode === 'split') { - const { dir, stem } = anchor(outputPath); - const { entry, schemas } = emitClientSplit(model, emit, stem); - return [ - ...(schemas === undefined - ? [] - : [{ path: join(dir, `${stem}.schemas.ts`), content: schemas }]), - { path: outputPath, content: entry }, - ]; - } - return [{ path: outputPath, content: emitClientSingleFile(model, emit) }]; -}; diff --git a/packages/client-generator/src/generators/swr/AGENTS.md b/packages/client-generator/src/generators/swr/AGENTS.md new file mode 100644 index 0000000000..696c35c8f1 --- /dev/null +++ b/packages/client-generator/src/generators/swr/AGENTS.md @@ -0,0 +1,41 @@ +# The `swr` generator — its skill + +This file is the generator's DESIGN and governs our own changes: **to change the +generator, edit this skill first, then make the code match it.** + +`npm run prepare` compiles it into `eject-assets/skills/swr-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + +## What it emits + +React SWR hooks over the sdk's exported operation functions: `use()` with a +`Key()` key factory for queries, `useSWRMutation` for mutations. + +## Design decisions that must hold + +- **Wraps the sdk's functions** — it never re-implements requests, so it requires `typescript` + and is throw-mode only. +- **Keys are exported factories** so consumers can invalidate precisely. +- **`envelope` is excluded** from hook options (`Omit`) and + stripped from the forwarded call: cached data is always the plain body. +- **Skips what it cannot wrap** — SSE operations and `Variables` name collisions — + with a warning naming each one, never silently. + +## Emitters that implement it + +`emitters/swr.ts`, `wrapper-support.ts` (shared wrappable-operation policy). + +## Ejecting it + +`redocly eject-generator swr` ships this generator BUNDLED with the emitter it uses — one +small `.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. +Change the hook shape or the key strategy, and regenerate. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Change the emitter modules named above (the entry is plumbing — it rarely moves). +3. Verify: `npm run compile`, the emitter unit suites + (`VITEST_SUITE=unit npx vitest run packages/client-generator/src/emitters`), the e2e + suites for this generator, and the large-description bars + (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/swr.ts b/packages/client-generator/src/generators/swr/index.ts similarity index 75% rename from packages/client-generator/src/generators/swr.ts rename to packages/client-generator/src/generators/swr/index.ts index 916a118ca9..e6443c6d52 100644 --- a/packages/client-generator/src/generators/swr.ts +++ b/packages/client-generator/src/generators/swr/index.ts @@ -1,16 +1,16 @@ 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 { HEADER } from '../../emitters/emit-options.js'; +import { renderSwrModule } from '../../emitters/swr.js'; +import { anchor } from '../anchor.js'; +import type { Generator } from '../types.js'; /** * The swr generator: a standalone `.swr.ts` module of SWR hooks wrapping the * sdk operation functions — `Key` + `use` (`useSWR`) per query (GET/HEAD), * `use` (`useSWRMutation`) per mutation. It imports the operation functions + * their `Variables` types from the sdk entry (`./.js`), so it requires the - * `sdk` generator and targets its throw-mode operation functions. `swr`/`swr/mutation` + * `typescript` generator and targets its throw-mode operation functions. `swr`/`swr/mutation` * are the consumer's peer; the sdk client stays dependency-free. * * Output-mode-agnostic: `./.js` resolves to the single-file client or the @@ -20,7 +20,6 @@ import type { Generator } from './types.js'; export const swrGenerator: Generator = ({ model, outputPath, emit }) => { const { dir, stem } = anchor(outputPath); const content = renderSwrModule(model, { - argsStyle: emit.argsStyle ?? 'flat', sdkModule: `./${stem}.${emit.importExt ?? 'js'}`, }); if (content === '') return []; diff --git a/packages/client-generator/src/generators/tanstack-query/AGENTS.md b/packages/client-generator/src/generators/tanstack-query/AGENTS.md new file mode 100644 index 0000000000..13719301f4 --- /dev/null +++ b/packages/client-generator/src/generators/tanstack-query/AGENTS.md @@ -0,0 +1,45 @@ +# The `tanstack-query` generator — its skill + +This file is the generator's DESIGN and governs our own changes: **to change the +generator, edit this skill first, then make the code match it.** + +`npm run prepare` compiles it into `eject-assets/skills/tanstack-query-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + +## What it emits + +Query/mutation option factories for TanStack Query — `Options()`, +`Mutation()`, and `InfiniteOptions()` for paginated operations — plus exported +query keys. One generator, four framework variants (`react` default, `-vue`, +`-svelte`, `-solid`) differing only in the imported package. + +## Design decisions that must hold + +- **Options factories, not hooks:** consumers call `useQuery(Options(...))`, so the + output works with any of the framework adapters and stays testable. +- **`queryKeyPrefix`** namespaces every key when several clients share a cache. +- **Infinite queries** derive `getNextPageParam` from the resolved pagination rule; a + `link`-style rule reads the `Link` header the descriptor declares. +- **`envelope` is excluded and stripped** — cached data is the plain body. +- Requires `typescript`; throw-mode only (it wraps thrown errors into query errors). + +## Emitters that implement it + +`emitters/tanstack-query.ts`, `wrapper-support.ts`, `pagination.ts`. + +## Ejecting it + +`redocly eject-generator tanstack-query` ships this generator BUNDLED with the emitter it +uses — one small `.mjs` you own, importing `@redocly/client-generator` and +`@redocly/openapi-core`. The framework is a single argument in the ejected file's default +export (`tanstackQueryGenerator('react')`), so switch it to `'vue'`, `'svelte'`, or +`'solid'` there instead of ejecting four near-identical copies. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Change the emitter modules named above (the entry is plumbing — it rarely moves). +3. Verify: `npm run compile`, the emitter unit suites + (`VITEST_SUITE=unit npx vitest run packages/client-generator/src/emitters`), the e2e + suites for this generator, and the large-description bars + (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/tanstack-query.ts b/packages/client-generator/src/generators/tanstack-query/index.ts similarity index 80% rename from packages/client-generator/src/generators/tanstack-query.ts rename to packages/client-generator/src/generators/tanstack-query/index.ts index 7a41664148..9a455da762 100644 --- a/packages/client-generator/src/generators/tanstack-query.ts +++ b/packages/client-generator/src/generators/tanstack-query/index.ts @@ -1,9 +1,9 @@ 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 { HEADER } from '../../emitters/emit-options.js'; +import { renderTanstackModule } from '../../emitters/tanstack-query.js'; +import { anchor } from '../anchor.js'; +import type { Generator } from '../types.js'; /** * The tanstack-query generator: a standalone `.tanstack.ts` module of @@ -12,7 +12,7 @@ import type { Generator } from './types.js'; * mutation, all built by `createQueryFactories(c)` (bindable to any client instance) * with the module-level exports bound to the sdk's default `client`. It imports the * `client` instance + the `Variables` types from the sdk entry (`./.js`), so - * it requires the `sdk` generator and its throw-mode client. The option helpers are + * it requires the `typescript` generator and its throw-mode client. The option helpers are * imported from `@tanstack/-query` (the consumer's peer); the registry binds * one framework per generator name, and the emitted body is byte-identical across them. * @@ -24,6 +24,7 @@ export function tanstackQueryGenerator(framework: 'react' | 'vue' | 'svelte' | ' return ({ model, outputPath, emit }) => { const { dir, stem } = anchor(outputPath); const content = renderTanstackModule(model, { + argsStyle: emit.argsStyle ?? 'grouped', sdkModule: `./${stem}.${emit.importExt ?? 'js'}`, framework, pagination: emit.pagination, diff --git a/packages/client-generator/src/generators/transformers/AGENTS.md b/packages/client-generator/src/generators/transformers/AGENTS.md new file mode 100644 index 0000000000..0485f196e0 --- /dev/null +++ b/packages/client-generator/src/generators/transformers/AGENTS.md @@ -0,0 +1,40 @@ +# The `transformers` generator — its skill + +This file is the generator's DESIGN and governs our own changes: **to change the +generator, edit this skill first, then make the code match it.** + +`npm run prepare` compiles it into `eject-assets/skills/transformers-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + +## What it emits + +Per-schema `to()` / `from()` converters that turn wire JSON into typed +values and back — the bridge for `dateType: Date` clients. + +## Design decisions that must hold + +- **Requires `dateType: Date`** (declared as `dateTypes: ['Date']`, so a mismatched + selection fails fast): the converters assign `Date` objects to fields the sdk types as + `Date`, which only type-checks in that mode. +- **Imports the sdk's schema TYPES** (so `typescript` is required) and nothing else. +- Converters are pure and total: every named schema gets a pair, nested structures + recurse, and a missing optional stays missing. + +## Emitters that implement it + +`emitters/transformers.ts`. + +## Ejecting it + +`redocly eject-generator transformers` ships this generator BUNDLED with the emitter it +uses — one small `.mjs` you own, importing `@redocly/client-generator` and +`@redocly/openapi-core`. Change which fields are converted, or how, and regenerate. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Change the emitter modules named above (the entry is plumbing — it rarely moves). +3. Verify: `npm run compile`, the emitter unit suites + (`VITEST_SUITE=unit npx vitest run packages/client-generator/src/emitters`), the e2e + suites for this generator, and the large-description bars + (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/transformers.ts b/packages/client-generator/src/generators/transformers/index.ts similarity index 85% rename from packages/client-generator/src/generators/transformers.ts rename to packages/client-generator/src/generators/transformers/index.ts index cbdb34f786..cb71f963d3 100644 --- a/packages/client-generator/src/generators/transformers.ts +++ b/packages/client-generator/src/generators/transformers/index.ts @@ -1,9 +1,9 @@ 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 { HEADER } from '../../emitters/emit-options.js'; +import { renderTransformersModule } from '../../emitters/transformers.js'; +import { anchor } from '../anchor.js'; +import type { Generator } from '../types.js'; /** * The transformers generator: a standalone `.transformers.ts` module of diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index 44fecb748c..331d26d34a 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -2,7 +2,7 @@ import type { EmitOptions } from '../emitters/emit-options.js'; import type { ErrorMode } from '../emitters/operations.js'; import type { DateType } from '../emitters/types.js'; -import type { ApiModel } from '../intermediate-representation/model.js'; +import type { ApiModel, OperationModel } from '../intermediate-representation/model.js'; /** * How the generated client is partitioned across files. @@ -18,7 +18,7 @@ export type GeneratedFile = { path: string; content: string }; /** The first-party generators the registry knows. */ export type GeneratorName = - | 'sdk' + | 'typescript' | 'zod' | 'tanstack-query' | 'tanstack-query-vue' @@ -26,7 +26,31 @@ export type GeneratorName = | 'tanstack-query-solid' | 'swr' | 'transformers' - | 'mock'; + | 'mock' + | 'cli' + | 'python' + | 'go' + | 'php'; + +/** + * One option a generator accepts: a scalar, a closed set of values, or a list of scalars. + * Config values are scalars and lists of scalars, so the schema vocabulary stops there — + * nothing a `redocly.yaml` block can express is missing. + */ +export type GeneratorOptionSchema = { default?: unknown; description?: string } & ( + | { type: 'string' | 'number' | 'boolean' } + | { type: 'array'; items: { type: 'string' | 'number' | 'boolean' } } + | { enum: Array } +); + +/** The options a generator declares, as the JSON Schema subset the config layer validates. */ +export type GeneratorOptionsSchema = { + type: 'object'; + properties: Record; + required?: string[]; + /** Unknown keys are rejected unless this is `true` — a typo'd option is a config bug. */ + additionalProperties?: boolean; +}; /** Everything a generator needs to produce its files. */ export type GeneratorInput = { @@ -37,6 +61,14 @@ export type GeneratorInput = { outputMode: OutputMode; /** Emit options — serverUrl, runtime, and the generator knobs (dateType, mockData, …); see `EmitOptions`. */ emit: EmitOptions; + /** Every generator name in the run — lets a generator adapt to co-selection (cli wires zod validation when `zod` is selected). */ + selected?: string[]; + /** + * This generator's own options from `client.options.`, already validated against + * the schema it declares with defaults applied — a generator reads them without re-checking. + * Empty when the generator declares no options. + */ + options?: Record; }; /** @@ -46,20 +78,44 @@ export type GeneratorInput = { */ export type Generator = (input: GeneratorInput) => GeneratedFile[]; +/** One idiomatic call snippet for an operation, rendered for docs (`x-codeSamples`). */ +export type CodeSample = { lang: string; label?: string; source: string }; + +/** + * What a `sample` hook receives besides the operation. `outputPath` is the `--output` + * anchor: a snippet has to import the module this run actually writes, and each language + * 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 }; + /** * A generator plus its declared compatibility contract. `validateGenerators` * checks these *before* anything is emitted, so an incompatible selection fails * fast with an actionable message instead of producing a client that won't compile. * * - `requires`: other generators that must also be selected (e.g. `tanstack-query` - * imports the sdk's operation functions, so it requires `sdk`). + * imports the client's operation functions, so it requires `typescript`). * - `errorModes` / `dateTypes` / `runtimes`: 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 sdk types + * supports only `throw` mode; `transformers` only type-checks when the client types * date fields as `Date`, so it supports only `dateType: 'Date'`.) */ export type GeneratorDescriptor = { run: Generator; + /** The options this generator accepts, validated before `run` (see `GeneratorOptionsSchema`). */ + options?: GeneratorOptionsSchema; + /** Optional: one idiomatic call snippet per operation for docs (`x-codeSamples`); + * collected into an overlay when `codeSamples` is enabled. Return undefined to skip. */ + sample?: (operation: OperationModel, ctx: SampleContext) => CodeSample | undefined; + /** + * Optional: the reference documentation for what `run` emits — a Markdown page per + * generated artifact, returned like `run`'s files. Called only when `client.docs` (or + * `--docs`) is on, so documentation is one switch for the whole run instead of a + * generator name per language. A generator documents ITSELF: nothing else knows its + * call syntax, and ejecting the generator takes its page with it. + */ + docs?: Generator; // `string[]` (not `GeneratorName[]`) so a custom generator may require a built-in or another // custom generator by name; built-in descriptors still type-check (their names are strings). requires?: string[]; @@ -67,6 +123,13 @@ export type GeneratorDescriptor = { 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 + * (`outputMode`, `runtime`, …) may be meaningful for one selected generator and + * meaningless for another, so this informs rather than rejects. + */ + notApplicable?: Partial>; }; /** @@ -78,4 +141,11 @@ export type GeneratorDescriptor = { export type CustomGenerator = GeneratorDescriptor & { /** Unique name, used in `generators` selection, `requires`, and collision detection. */ name: string; + /** + * The `@redocly/client-generator` version range this module was written against — + * `^1.2.0`, `~1.2.0`, `>=1.2.0`, or an exact version. A CLI outside the range is + * rejected at resolve time with the fix path; omitting it accepts the generator as + * current (friction-free hand authoring). Ejected generators carry it automatically. + */ + requiresGenerator?: string; }; diff --git a/packages/client-generator/src/generators/typescript/AGENTS.md b/packages/client-generator/src/generators/typescript/AGENTS.md new file mode 100644 index 0000000000..8a5b884cd8 --- /dev/null +++ b/packages/client-generator/src/generators/typescript/AGENTS.md @@ -0,0 +1,78 @@ +# The `typescript` generator — its skill + +This file is the generator's DESIGN and governs our own changes: **to change the +generator, edit this skill first, then make the code match it** — a diff with no +covering sentence here is incomplete. + +`npm run prepare` compiles it into `eject-assets/skills/typescript-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + +## What it emits + +The typed TypeScript client itself: model types with JSDoc, type guards, the `Ops` +type map, the `OPERATIONS` descriptor table, a `client` instance, one binding per +operation, and either the embedded runtime (`runtime: inline`) or imports from +`@redocly/client-generator` (`runtime: package`). + +## Design decisions that must hold + +- **Descriptor-driven:** generated code is DATA (`OPERATIONS` + `Ops`) plus wiring; + request behavior lives in the runtime, never in per-operation code. + `satisfies Record` is the version-skew guard. +- **`single` vs `split`:** split derives `.schemas.ts` (types, enums, guards) and + an entry that `export *`s it; the entry type-imports only the schema names it + references (`collectEntrySchemaRefs`). +- **Zero runtime dependencies.** `Date`, `Blob`, `fetch` — nothing else. +- **Names are collision-safe:** `packageIdents` seeds every reserved wiring name before + any operation is sanitized, so renames are deterministic (`configure` → `configure_2`). + A rename becomes part of the SDK's public API, so the warning must say WHICH cause it + is and what the publisher can do: a duplicate `operationId` in the description (fix the + description — the only real fix), a name that isn't a valid identifier, or a clash with + a name the generated module already declares. A vague "collides or is invalid" message + leaves the publisher unable to act. +- **One operation, one function, one input shape.** The module-level names are bindings + of the client's own methods (`export const { getOrder } = client;`), never wrappers, so + `getOrder` and `client.getOrder` cannot disagree about their arguments. `argsStyle` + shapes the method itself: `grouped` (the default) namespaces the inputs by transport + layer — `path`, `query`, `headers`, `cookies`, `body` — and `flat` merges them into one + object, which the runtime converts back using the descriptor's own parameter list. An + operation whose merged names would collide keeps the grouped shape. +- **Throw mode returns the body**; `{ envelope: true }` opts into + `{ data, headers, response }` with typed declared headers. Result mode returns + `{ data, error, response }` and ignores `envelope`. + +## Emitters that implement it + +`emitters/client-assembly.ts` (orchestration), `render-client.ts` (Ops, aliases, input +shapes), `descriptor.ts`, `ts-type.ts`/`ts-literal.ts` (type + data text), `sse.ts`, +`pagination.ts`, `response-headers.ts`, `inline-runtime.ts`, `setup-bake.ts`. + +## Ejecting it + +`redocly eject-generator typescript` ships this generator BUNDLED with the emitters it uses — +one `.mjs` you own, unminified, with a comment marking each source module. It imports +only `@redocly/client-generator` (the toolkit and the embedded runtime) and +`@redocly/openapi-core` (`logger`, `isPlainObject`), so runtime fixes still arrive by +`npm update`. + +It is the largest of them (the whole client emitter plus the runtime it embeds), so reach +for the smaller paths first when they fit: `client.setup` bakes publisher defaults into the +generated client, and middleware or `configure()` change behavior at run time rather than +generation time. + +- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes + `.typescript.md`: the security schemes, then one section per operation with its parameters, + body, response type, and behavior notes. The call snippets come from this generator's own + `sample` hook, so the page can only show the syntax of the SDK beside it, and the layout + comes from `renderReferencePage` in the authoring toolkit — reachable from an ejected copy + through `@redocly/client-generator`. Pagination on the page is decided by + `paginationRuleFor`, the same helper this generator resolves pagination with. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Change the emitter modules named above (the entry is plumbing — it rarely moves). +3. Verify: `npm run compile`, the emitter unit suites + (`VITEST_SUITE=unit npx vitest run packages/client-generator/src/emitters`), the e2e + suites for this generator, and the large-description bars + (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/typescript/index.ts b/packages/client-generator/src/generators/typescript/index.ts new file mode 100644 index 0000000000..e690ee9ac6 --- /dev/null +++ b/packages/client-generator/src/generators/typescript/index.ts @@ -0,0 +1,80 @@ +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'; + +/** + * The default generator: the full typed client (model types + runtime + endpoints). + * Other generators (zod, framework hooks) emit *additional* files alongside. + * + * `single` mode writes the whole client to the `--output` path. `split` mode derives + * two sibling files from that anchor — `.schemas.ts` (model types, enums, + * 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 }) => { + if (outputMode === 'split') { + const { dir, stem } = anchor(outputPath); + const { entry, schemas } = emitClientSplit(model, emit, stem); + return [ + ...(schemas === undefined + ? [] + : [{ path: join(dir, `${stem}.schemas.ts`), content: schemas }]), + { path: outputPath, content: entry }, + ]; + } + return [{ path: outputPath, content: emitClientSingleFile(model, emit) }]; +}; + +/** + * The client's own reference page, written when `client.docs` is on. Its snippets come from + * `typescriptSample` below, so the page shows the calling convention this run generated — + * `argsStyle` included. + */ +export const typescriptDocs: Generator = ({ model, outputPath, emit }) => [ + { + path: outputPath.replace(/\.[^.\\/]+$/, '.typescript.md'), + content: renderReferencePage(model, { + title: `${model.title} TypeScript client reference`, + frontmatter: emit.docsFrontmatter === true, + language: { + name: 'typescript', + label: 'TypeScript', + fence: 'typescript', + requires: 'The client has no dependencies.', + }, + sample: (op) => typescriptSample(op, { model, emit, outputPath }), + pagination: emit.pagination, + }), + }, +]; + +/** One idiomatic TS call per operation, for `x-codeSamples` and the SDK reference pages. */ +export function typescriptSample(op: OperationModel, ctx: SampleContext): CodeSample { + const ident = packageIdents(ctx.model).get(op.name) ?? op.name; + // The module this run writes, with the run's import extension — `./client` would be + // both the wrong name for most stems and extensionless under ESM resolution. + const stem = ctx.outputPath.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, ''); + const specifier = `./${stem}.${ctx.emit.importExt ?? 'js'}`; + const requiredQuery = op.queryParams.filter((param) => param.required); + const merged = ctx.emit.argsStyle === 'flat'; + const path = op.pathParams.map((param) => `${param.name}: '<${param.name}>'`); + const query = requiredQuery.map((param) => `${param.name}: /* … */`); + const parts = merged + ? [...path, ...query, ...(op.requestBody ? ['/* body properties */'] : [])] + : [ + ...(path.length > 0 ? [`path: { ${path.join(', ')} }`] : []), + ...(query.length > 0 ? [`query: { ${query.join(', ')} }`] : []), + ...(op.requestBody ? ['body: { /* … */ }'] : []), + ]; + const args = parts.length > 0 ? [`{ ${parts.join(', ')} }`] : []; + return { + lang: 'typescript', + label: 'TypeScript SDK', + source: `import { ${ident} } from '${specifier}';\n\nconst result = await ${ident}(${args.join(', ')});\n`, + }; +} diff --git a/packages/client-generator/src/generators/zod/AGENTS.md b/packages/client-generator/src/generators/zod/AGENTS.md new file mode 100644 index 0000000000..ade51b766f --- /dev/null +++ b/packages/client-generator/src/generators/zod/AGENTS.md @@ -0,0 +1,46 @@ +# The `zod` generator — its skill + +This file is the generator's DESIGN and governs our own changes: **to change the +generator, edit this skill first, then make the code match it.** + +`npm run prepare` compiles it into `eject-assets/skills/zod-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + +## What it emits + +A standalone `.zod.ts`: one `export const Schema` per named IR schema, the +`operationSchemas` request/response map, and a `zodValidation()` middleware. + +## Design decisions that must hold + +- **The client stays dependency-free.** zod is the CONSUMER's peer dependency; the + generated client never imports this module, and this module never imports the client. +- **Output-mode-agnostic:** one module beside the client whatever the sdk's layout. +- **Emits nothing** when the model has neither named schemas nor JSON operation bodies — + an empty file is worse than no file. +- Validation is opt-in at runtime (`use(zodValidation())`), never automatic. +- **Only ERASABLE TypeScript.** The module must run under `node --experimental-strip-types` + with no build step, so nothing that needs a transform is emitted: no `enum`, no + `namespace`, and no constructor parameter properties. `ZodValidationError` therefore + declares its fields and assigns them in the constructor body — `constructor(readonly +operationId: string)` fails strip-only mode, which is how the generated CLI broke when it + imported this module. + +## Emitters that implement it + +`emitters/zod.ts` (schema expressions + module assembly). + +## Ejecting it + +`redocly eject-generator zod` ships this generator BUNDLED with the emitter it uses — one +small `.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. +Change the schema shapes, the naming, or what gets a schema at all, and regenerate. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Change the emitter modules named above (the entry is plumbing — it rarely moves). +3. Verify: `npm run compile`, the emitter unit suites + (`VITEST_SUITE=unit npx vitest run packages/client-generator/src/emitters`), the e2e + suites for this generator, and the large-description bars + (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/zod.ts b/packages/client-generator/src/generators/zod/index.ts similarity index 82% rename from packages/client-generator/src/generators/zod.ts rename to packages/client-generator/src/generators/zod/index.ts index 2c93b5fa97..981dba25bb 100644 --- a/packages/client-generator/src/generators/zod.ts +++ b/packages/client-generator/src/generators/zod/index.ts @@ -1,9 +1,9 @@ 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 { HEADER } from '../../emitters/emit-options.js'; +import { renderZodModule } from '../../emitters/zod.js'; +import { anchor } from '../anchor.js'; +import type { Generator } from '../types.js'; /** * The zod generator: a standalone `.zod.ts` module of Zod schemas (one diff --git a/packages/client-generator/src/index.ts b/packages/client-generator/src/index.ts index 848a0e4d55..caf6f35d65 100644 --- a/packages/client-generator/src/index.ts +++ b/packages/client-generator/src/index.ts @@ -3,6 +3,9 @@ // 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 language-neutral generator-authoring toolkit — pure functions over the IR, +// safe on this runtime-only entry (no typescript, no openapi-core, no builtins). +export * from './authoring/index.js'; export { NotSupportedError } from './errors.js'; export { defineClientSetup } from './runtime-contract.js'; export type { @@ -47,7 +50,18 @@ export type { SseOptions, TokenProvider, } from './runtime/index.js'; -// The user-facing pagination rule shapes (`Config.pagination` / `x-redocly-pagination`). +// The generated-CLI engine (package-mode cli files import it from the package root). +export { invokedName, runCli } from './runtime/cli.js'; +export type { + CliAuthScheme, + CliCommand, + CliGlobals, + CliWiring, + CommandContext, + CommandSource, + CustomCommand, +} from './runtime/cli.js'; +// The user-facing pagination rule shapes (`Config.pagination` / `x-redoclyPagination`). export type { PaginationConfig, PaginationRule, PaginationStyle } from './emitters/pagination.js'; export type { GenerateClientConfig, @@ -65,6 +79,6 @@ import type { GenerateClientOptions, GenerateClientResult } from './types.js'; export async function generateClient( options: GenerateClientOptions ): Promise { - const generate = await import('./generate.js'); - return generate.generateClient(options); + const pipeline = await import('./pipeline.js'); + return pipeline.generateClient(options); } diff --git a/packages/client-generator/src/intermediate-representation/__tests__/__snapshots__/contract-shape.test.ts.snap b/packages/client-generator/src/intermediate-representation/__tests__/__snapshots__/contract-shape.test.ts.snap new file mode 100644 index 0000000000..951288f1bc --- /dev/null +++ b/packages/client-generator/src/intermediate-representation/__tests__/__snapshots__/contract-shape.test.ts.snap @@ -0,0 +1,201 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`IR contract shape > pins the full ApiModel a generator receives for a representative document 1`] = ` +{ + "schemas": [ + { + "name": "Order", + "schema": { + "kind": "object", + "properties": [ + { + "name": "id", + "readOnly": true, + "required": true, + "schema": { + "kind": "scalar", + "scalar": "string", + }, + }, + { + "name": "status", + "required": false, + "schema": { + "kind": "ref", + "name": "Status", + }, + }, + ], + }, + }, + { + "name": "Status", + "schema": { + "kind": "enum", + "scalar": "string", + "values": [ + "open", + "shipped", + ], + }, + }, + { + "name": "Pet", + "schema": { + "discriminator": { + "mapping": [ + { + "schemaName": "Order", + "value": "order", + }, + ], + "propertyName": "kind", + }, + "kind": "union", + "members": [ + { + "kind": "ref", + "name": "Order", + }, + ], + }, + }, + ], + "securitySchemes": [ + { + "key": "BearerAuth", + "kind": "bearer", + }, + ], + "serverUrl": "https://api.example.com/us", + "servers": [ + { + "description": "Live server", + "url": "https://api.example.com/{region}", + "variables": [ + { + "default": "us", + "name": "region", + }, + ], + }, + ], + "services": [ + { + "name": "Default", + "operations": [ + { + "cookieParams": [], + "errorResponses": [ + { + "contentType": "application/json", + "schema": { + "kind": "record", + "value": { + "kind": "unknown", + }, + }, + "status": 404, + }, + ], + "headerParams": [ + { + "in": "header", + "name": "X-Trace", + "required": false, + "schema": { + "kind": "scalar", + "scalar": "string", + }, + }, + ], + "method": "get", + "name": "listOrders", + "path": "/orders", + "pathParams": [], + "queryParams": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "kind": "scalar", + "metadata": { + "minimum": 1, + }, + "scalar": "integer", + }, + }, + ], + "security": [ + [ + "BearerAuth", + ], + ], + "successResponseHeaders": [ + { + "name": "pagination-total", + "required": true, + "schema": { + "kind": "scalar", + "scalar": "integer", + }, + }, + ], + "successResponses": [ + { + "contentType": "application/json", + "schema": { + "items": { + "kind": "ref", + "name": "Order", + }, + "kind": "array", + }, + "status": 200, + }, + ], + "tags": [ + "Orders", + ], + }, + { + "cookieParams": [], + "errorResponses": [], + "headerParams": [], + "method": "post", + "name": "createOrder", + "path": "/orders", + "pathParams": [], + "queryParams": [], + "requestBody": { + "contentType": "application/json", + "required": true, + "schema": { + "base": "Order", + "keys": [ + "id", + ], + "kind": "omit", + }, + }, + "security": [], + "successResponses": [ + { + "contentType": "application/json", + "schema": { + "kind": "ref", + "name": "Order", + }, + "status": 201, + }, + ], + "tags": [], + }, + ], + }, + ], + "title": "Contract Probe", + "version": "1.0.0", +} +`; 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 ff84090055..e5f4b65df3 100644 --- a/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts +++ b/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts @@ -254,24 +254,24 @@ describe('buildOperation — tags', () => { }); }); -describe('buildOperation — x-redocly-pagination extension', () => { - it('captures the x-redocly-pagination value verbatim, without validation', () => { +describe('buildOperation — x-redoclyPagination extension', () => { + it('captures the x-redoclyPagination value verbatim, without validation', () => { const extension = { style: 'cursor', cursorParam: 'cursor', bogus: 42 }; const op = buildOpOnly({ paths: { '/orders': { - get: { operationId: 'listOrders', 'x-redocly-pagination': extension, responses: {} }, + get: { operationId: 'listOrders', 'x-redoclyPagination': extension, responses: {} }, } as never, }, }); expect(op.paginationExtension).toBe(extension); }); - it('captures a non-object x-redocly-pagination value too (validated by the emitter, not the IR)', () => { + it('captures a non-object x-redoclyPagination value too (validated by the emitter, not the IR)', () => { const op = buildOpOnly({ paths: { '/orders': { - get: { operationId: 'listOrders', 'x-redocly-pagination': 'nonsense', responses: {} }, + get: { operationId: 'listOrders', 'x-redoclyPagination': 'nonsense', responses: {} }, } as never, }, }); @@ -1845,6 +1845,75 @@ describe('buildApiModel — request body readOnly stripping', () => { }); }); + // OpenAPI 3.1 is JSON Schema 2020-12: `$ref` is an ordinary keyword, so keywords + // beside it take effect (this repo's own `spec-ref-siblings` rule says as much). + // Dropping them left server-computed properties in every request body. + it('applies a readOnly sibling of a $ref in OpenAPI 3.1', () => { + const op = buildOpOnly({ + openapi: '3.1.0', + components: { + schemas: { + Computed: { type: 'object', properties: { tier: { type: 'string' } } }, + Widget: { + type: 'object', + required: ['name', 'refComputed'], + properties: { + name: { type: 'string' }, + refComputed: { $ref: '#/components/schemas/Computed', readOnly: true }, + }, + }, + }, + } as never, + paths: { + '/widgets': { + post: { + operationId: 'createWidget', + requestBody: { + required: true, + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Widget' } }, + }, + }, + responses: { '201': { description: 'ok' } }, + }, + }, + }, + } as Partial); + expect(op.requestBody?.schema).toEqual({ + kind: 'omit', + base: 'Widget', + keys: ['refComputed'], + }); + }); + + it('ignores a readOnly sibling in OpenAPI 3.0, where a $ref replaces the schema, and says so', () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + try { + const op = postBody( + { + Computed: { type: 'object', properties: { tier: { type: 'string' } } }, + Widget: { + type: 'object', + required: ['name', 'refComputed'], + properties: { + name: { type: 'string' }, + refComputed: { $ref: '#/components/schemas/Computed', readOnly: true }, + }, + }, + }, + { $ref: '#/components/schemas/Widget' } + ); + // 3.0 semantics: the sibling has no meaning, so the property stays sendable… + expect(op.requestBody?.schema).toEqual({ kind: 'ref', name: 'Widget' }); + // …but the intent is obvious enough that silence would be the wrong answer. + const messages = warn.mock.calls.map(([message]) => message).join(''); + expect(messages).toContain('refComputed'); + expect(messages).toContain('readOnly'); + } finally { + warn.mockRestore(); + } + }); + it('collects readOnly keys through allOf members (deduped)', () => { const op = postBody( { diff --git a/packages/client-generator/src/intermediate-representation/__tests__/contract-shape.test.ts b/packages/client-generator/src/intermediate-representation/__tests__/contract-shape.test.ts new file mode 100644 index 0000000000..909b80ad38 --- /dev/null +++ b/packages/client-generator/src/intermediate-representation/__tests__/contract-shape.test.ts @@ -0,0 +1,96 @@ +// The IR is the custom-generator contract: every field below is public API that +// ejected and custom generators read. If this snapshot changes, decide whether the +// change is ADDITIVE (update the snapshot and ship it in any release) or BREAKING +// (a removed/renamed field, or changed semantics), which needs a major release — +// the minor while the package is 0.x. A generator's `requiresGenerator` range is +// resolved against that version, so a breaking change stops incompatible +// generators with the fix path instead of letting them misbehave. + +import type { Oas3Definition } from '@redocly/openapi-core'; + +import { buildApiModel } from '../build.js'; + +const DOC = { + openapi: '3.1.0', + info: { title: 'Contract Probe', version: '1.0.0' }, + servers: [ + { + url: 'https://api.example.com/{region}', + description: 'Live server', + variables: { region: { default: 'us' } }, + }, + ], + paths: { + '/orders': { + get: { + operationId: 'listOrders', + tags: ['Orders'], + parameters: [ + { name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1 } }, + { name: 'X-Trace', in: 'header', schema: { type: 'string' } }, + ], + responses: { + '200': { + description: 'ok', + headers: { + 'Pagination-Total': { schema: { type: 'integer' }, required: true }, + }, + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/Order' } }, + }, + }, + }, + '404': { + description: 'missing', + content: { 'application/json': { schema: { type: 'object' } } }, + }, + }, + security: [{ BearerAuth: [] }], + }, + post: { + operationId: 'createOrder', + requestBody: { + required: true, + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Order' } }, + }, + }, + responses: { + '201': { + description: 'created', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Order' } }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Order: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string', readOnly: true }, + status: { $ref: '#/components/schemas/Status' }, + }, + }, + Status: { type: 'string', enum: ['open', 'shipped'] }, + Pet: { + oneOf: [{ $ref: '#/components/schemas/Order' }], + discriminator: { propertyName: 'kind', mapping: { order: '#/components/schemas/Order' } }, + }, + }, + securitySchemes: { + BearerAuth: { type: 'http', scheme: 'bearer' }, + }, + }, +} as unknown as Oas3Definition; + +describe('IR contract shape', () => { + it('pins the full ApiModel a generator receives for a representative document', () => { + expect(JSON.parse(JSON.stringify(buildApiModel(DOC)))).toMatchSnapshot(); + }); +}); diff --git a/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts b/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts index 9b8d324a0e..0da20105b0 100644 --- a/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts +++ b/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts @@ -1,9 +1,7 @@ +import { logger } from '@redocly/openapi-core'; + import type { ApiModel, OperationModel, SchemaModel } from '../model.js'; -import { - assertPathParamsAvoidArgSlots, - assertSafeIdentifiers, - sanitizeIdentifiers, -} from '../sanitize-identifiers.js'; +import { assertSafeIdentifiers, sanitizeIdentifiers } from '../sanitize-identifiers.js'; function model(schemas: ApiModel['schemas'], operations: OperationModel[] = []): ApiModel { return { @@ -133,15 +131,16 @@ describe('sanitizeIdentifiers', () => { expect(m.schemas.map((schema) => schema.name)).toEqual(['Date_2', 'Promise_2']); }); - it('renames a schema that collides with an auth setter derived from the security schemes', () => { - // A bearer scheme makes the sugar emit `export const setBearer = …`; a string-enum - // schema of the same name emits an `export const` companion — a duplicate declaration. + it('keeps a schema named after a former auth setter: nothing exports that name now', () => { + // The generator used to emit `export const setBearer = …` for a bearer scheme, which + // collided with a string-enum schema's `export const` companion. Credentials moved to + // `configure`/`client.auth`, so no generated export claims the name. const m = model([ { name: 'setBearer', schema: { kind: 'enum', scalar: 'string', values: ['a', 'b'] } }, ]); m.securitySchemes = [{ kind: 'bearer', key: 'bearerAuth' }]; sanitizeIdentifiers(m); - expect(m.schemas[0].name).toBe('setBearer_2'); + expect(m.schemas[0].name).toBe('setBearer'); }); it('renames an operation that collides with a runtime declaration', () => { @@ -304,31 +303,6 @@ describe('sanitizeIdentifiers', () => { }); }); -describe('assertPathParamsAvoidArgSlots', () => { - function opWithPathParam(name: string): OperationModel { - return op({ - path: `/x/{${name}}`, - pathParams: [ - { name, in: 'path', required: true, schema: { kind: 'scalar', scalar: 'string' } }, - ], - }); - } - - it('throws for a path parameter named after a request-args slot', () => { - // The runtime routes path values as `args[param.name]` at the top level, next to the - // `params`/`body`/`headers`/`cookies` slots — a same-named path param is ambiguous. - const m = model([], [opWithPathParam('body')]); - expect(() => assertPathParamsAvoidArgSlots(m)).toThrow( - /path parameter "body".*rename the parameter/s - ); - }); - - it('allows a path parameter named init (only a flat-sugar binding, remapped there)', () => { - const m = model([], [opWithPathParam('init')]); - expect(() => assertPathParamsAvoidArgSlots(m)).not.toThrow(); - }); -}); - describe('assertSafeIdentifiers', () => { it('passes for a fully sanitized model', () => { const m = model( @@ -356,3 +330,58 @@ describe('assertSafeIdentifiers', () => { ); }); }); + +describe('rename warnings name the cause and the fix', () => { + function warningsFor(build: () => void): string { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + try { + build(); + return warn.mock.calls.map(([message]) => message).join(''); + } finally { + warn.mockRestore(); + } + } + + it('says the description has a duplicate operationId — the only real fix', () => { + const messages = warningsFor(() => + sanitizeIdentifiers( + model([], [op({ name: 'patchCreditMemo' }), op({ name: 'patchCreditMemo' })]) + ) + ); + expect(messages).toContain('two operations share the operationId "patchCreditMemo"'); + expect(messages).toContain('patchCreditMemo_2'); + expect(messages).toContain('give each operation a unique operationId'); + // The old message blamed the identifier and offered nothing to act on. + expect(messages).not.toContain('is not a valid TypeScript identifier'); + }); + + it('says which reserved name a schema clashed with', () => { + const messages = warningsFor(() => + sanitizeIdentifiers(model([{ name: 'Error', schema: { kind: 'unknown' } }])) + ); + expect(messages).toContain('schema "Error"'); + expect(messages).toContain('a name a generated client already declares'); + expect(messages).toContain('Error_2'); + // The reserved set is the union across languages, so the name is the same in every SDK. + expect(messages).toContain('spans every target language'); + }); + + it('says a name was not a usable identifier when that is the actual cause', () => { + const messages = warningsFor(() => + sanitizeIdentifiers(model([{ name: 'not a name!', schema: { kind: 'unknown' } }])) + ); + expect(messages).toContain('is not a usable identifier'); + }); + it('says an operation collided with a schema of the same name — the common real case', () => { + const messages = warningsFor(() => + sanitizeIdentifiers( + model( + [{ name: 'PatchCreditMemo', schema: { kind: 'unknown' } }], + [op({ name: 'PatchCreditMemo' })] + ) + ) + ); + expect(messages).toContain('collides with the schema of the same name'); + expect(messages).toContain('rename the operation or the schema in the description'); + }); +}); diff --git a/packages/client-generator/src/intermediate-representation/build.ts b/packages/client-generator/src/intermediate-representation/build.ts index a08a9ee84c..bc45b2a5dd 100644 --- a/packages/client-generator/src/intermediate-representation/build.ts +++ b/packages/client-generator/src/intermediate-representation/build.ts @@ -35,11 +35,7 @@ import type { SecuritySchemeModel, ServiceModel, } from './model.js'; -import { - assertPathParamsAvoidArgSlots, - assertSafeIdentifiers, - sanitizeIdentifiers, -} from './sanitize-identifiers.js'; +import { assertSafeIdentifiers, sanitizeIdentifiers } from './sanitize-identifiers.js'; type Oas3SecurityScheme = { type?: string; @@ -221,6 +217,15 @@ export function buildApiModel(doc: Oas3Definition): ApiModel { const version = doc.info?.version ?? '0.0.0'; const description = doc.info?.description; const serverUrl = resolveServerUrl(doc.servers?.[0]); + const servers = (doc.servers ?? []).map((server) => ({ + url: server.url, + description: server.description, + variables: Object.entries(server.variables ?? {}).map(([name, variable]) => ({ + name, + default: variable.default, + description: variable.description, + })), + })); const schemas = buildNamedSchemas(doc); const securitySchemes = buildSecuritySchemes(doc); @@ -231,6 +236,7 @@ export function buildApiModel(doc: Oas3Definition): ApiModel { version, description, serverUrl, + servers, services, schemas, securitySchemes, @@ -243,7 +249,6 @@ export function buildApiModel(doc: Oas3Definition): ApiModel { // Hard gate: no unsafe name may reach the printer (see sanitize-identifiers.ts). assertSafeIdentifiers(model); // A path parameter named like a request-args slot cannot be routed — fail loudly. - assertPathParamsAvoidArgSlots(model); return model; } @@ -530,9 +535,8 @@ function buildOperation( const security = resolveOperationSecurity(operation, doc, injectable); // Extensions aren't in the @redocly operation type — read loosely, like `deprecated`. - const paginationExtension = (operation as unknown as Record)[ - 'x-redocly-pagination' - ]; + const extensions = operation as unknown as Record; + const paginationExtension = extensions['x-redoclyPagination']; return { name, @@ -975,6 +979,17 @@ function scalarForEnumValues(values: unknown[], location: string): ScalarKind { return 'string'; } +/** + * Whether keywords beside a `$ref` apply. OpenAPI 3.1 is JSON Schema 2020-12, where + * `$ref` is an ordinary keyword and its siblings take effect; 3.0 and 2.0 predate that + * and a `$ref` replaces the whole schema object, so siblings mean nothing (the + * `spec-ref-siblings` lint rule reports them). Swagger 2 arrives here normalized to + * `3.0.3`, so it takes the 3.0 path. + */ +function refSiblingsApply(doc: Oas3Definition): boolean { + return !(doc.openapi ?? '').startsWith('3.0'); +} + function buildProperties( schema: Oas3Schema, location: string, @@ -982,8 +997,18 @@ function buildProperties( ): PropertyModel[] { const props = schema.properties ?? {}; const required = new Set(schema.required ?? []); + const siblingsApply = refSiblingsApply(doc); return Object.entries(props).map(([name, sub]) => { - const readOnly = !isRef(sub) && (sub as { readOnly?: boolean }).readOnly === true; + const declared = (sub as { readOnly?: boolean }).readOnly === true; + // A `readOnly` sibling on a 3.0 `$ref` is a no-op the author almost certainly did + // not intend — it leaves a server-computed property in every request body — so it + // is reported rather than dropped in silence. + if (declared && isRef(sub) && !siblingsApply) { + logger.warn( + `generate-client: "${name}" declares readOnly beside a $ref, which OpenAPI ${doc.openapi} ignores — the property stays in request bodies. Inline the schema, wrap the $ref in allOf, or move the description to OpenAPI 3.1.\n` + ); + } + const readOnly = declared && (siblingsApply || !isRef(sub)); return { name, schema: schemaFromSlot(sub, `${location}.${name}`, doc), diff --git a/packages/client-generator/src/intermediate-representation/model.ts b/packages/client-generator/src/intermediate-representation/model.ts index 9517263734..b0c3e08a3f 100644 --- a/packages/client-generator/src/intermediate-representation/model.ts +++ b/packages/client-generator/src/intermediate-representation/model.ts @@ -223,7 +223,7 @@ export type OperationModel = { */ security: string[][]; /** - * The operation's `x-redocly-pagination` extension value, captured VERBATIM (spec + * The operation's `x-redoclyPagination` extension value, captured VERBATIM (spec * extensions are untyped). Validated by the pagination emitter, not the IR. */ paginationExtension?: unknown; @@ -240,11 +240,25 @@ export type NamedSchemaModel = { description?: string; }; +export type ServerVariableModel = { + name: string; + default: string; + description?: string; +}; + +/** One declared server, URL kept TEMPLATED — `serverUrl` carries the substituted default. */ +export type ServerModel = { + url: string; + description?: string; + variables: ServerVariableModel[]; +}; + export type ApiModel = { title: string; version: string; description?: string; serverUrl: string; + servers?: ServerModel[]; services: ServiceModel[]; schemas: NamedSchemaModel[]; securitySchemes: SecuritySchemeModel[]; diff --git a/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts b/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts index b6f02bae21..d88e752d4b 100644 --- a/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts +++ b/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts @@ -1,10 +1,8 @@ import { logger } from '@redocly/openapi-core'; -import { authSetterNames } from '../emitters/auth.js'; import { isSafeIdentifier, sanitizeIdentifier } from '../emitters/identifier.js'; import { reservedModuleNames } from '../emitters/reserved-names.js'; import { pascalCase } from '../emitters/support.js'; -import { NotSupportedError } from '../errors.js'; import type { ApiModel, OperationModel, SchemaModel } from './model.js'; /** @@ -36,27 +34,24 @@ export function sanitizeIdentifiers(model: ApiModel): void { const safe = uniquePascalIdent(scheme.key, schemeKeys, schemePascals); if (safe !== scheme.key) { renamedKeys.set(scheme.key, safe); - warnRename('security scheme', scheme.key, safe); + warnRename('security scheme', scheme.key, safe, causeFor(scheme.key, schemeKeys)); scheme.key = safe; } } // Schema types land in the same module scope as everything the generator emits and // embeds, so a schema may not reuse a reserved name (the runtime's `ApiError` class, - // a satellite import like msw's `http`, the `client` const, an auth setter, …). The - // rename is mode-independent — a `--runtime` flip must not change the generated - // type names. - const schemaNames = new Set([ - ...reservedModuleNames(), - ...authSetterNames(model.securitySchemes), - ]); + // a satellite import like msw's `http`, the `client` const, …). The rename is + // mode-independent — a `--runtime` flip must not change the generated type names. + const reservedNames = new Set(reservedModuleNames()); + const schemaNames = new Set(reservedNames); const schemaPascals = new Set(); const renamed = new Map(); for (const schema of model.schemas) { const safe = uniquePascalIdent(schema.name, schemaNames, schemaPascals); if (safe !== schema.name) { renamed.set(schema.name, safe); - warnRename('schema', schema.name, safe); + warnRename('schema', schema.name, safe, causeFor(schema.name, reservedNames)); schema.name = safe; } } @@ -73,11 +68,24 @@ export function sanitizeIdentifiers(model: ApiModel): void { // shadowed type. Seeding with the schema names renames a colliding operation. const operationNames = new Set(schemaNames); const operationPascals = new Set(); + const seenOperationIds = new Set(); for (const service of model.services) { for (const op of service.operations) { + // Recorded for EVERY operation, renamed or not: the second occurrence of an + // operationId is what identifies a duplicate in the description. + const duplicate = seenOperationIds.has(op.name); + seenOperationIds.add(op.name); + const original = op.name; const safe = uniquePascalIdent(op.name, operationNames, operationPascals); if (safe !== op.name) { - warnRename('operation', op.name, safe); + const cause: RenameCause = duplicate + ? { kind: 'duplicate-operation-id' } + : reservedNames.has(original) + ? { kind: 'reserved' } + : schemaNames.has(original) + ? { kind: 'schema-collision' } + : { kind: 'unusable' }; + warnRename('operation', original, safe, cause); op.specName = op.name; op.name = safe; } @@ -105,31 +113,6 @@ function uniquePascalIdent(name: string, used: Set, usedPascals: Set): RenameCause { + return taken.has(from) ? { kind: 'reserved' } : { kind: 'unusable' }; } /** Rewrite `ref`/`omit`/discriminator targets in a schema subtree via `fixRef` (mutates). */ diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts new file mode 100644 index 0000000000..6c5bd083f9 --- /dev/null +++ b/packages/client-generator/src/pipeline.ts @@ -0,0 +1,283 @@ +// The generation pipeline: loadSpec → IR → resolve generators → run → write. +// This module must stay free of static `typescript` imports (pinned by +// pipeline-ts-free.test.ts): built-in generators load lazily through +// generators/meta.js, and the TS-specific setup baking loads on demand — so a +// run selecting only non-TypeScript generators never loads the `typescript` +// package. The `/generate` entry re-exports `generateClient` from here and +// layers the sync TS toolkit on top. + +import { logger, stringifyYaml } from '@redocly/openapi-core'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, 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, + GeneratedFile, + GeneratorDescriptor, + OutputMode, +} from './generators/types.js'; +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 type { GenerateClientOptions, GenerateClientResult } from './types.js'; + +/** + * Run each generator of a fully-loaded registry against the IR and concatenate + * their files. Throws on a duplicate output path so two generators can't + * silently clobber each other. Validation is the caller's job (`validateSelection`). + */ +export function runGenerators( + model: ApiModel, + options: { + outputPath: string; + outputMode: OutputMode; + emit: EmitOptions; + generators: string[]; + registry: Map; + /** Per-generator options, already validated (see `resolveGeneratorOptions`). */ + generatorOptions?: Map>; + } +): GeneratedFile[] { + const files: GeneratedFile[] = []; + const seen = new Set(); + // 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)); + let documented = false; + for (const name of options.generators) { + const generator = options.registry.get(name)!; + const input = { + model, + outputPath: options.outputPath, + outputMode: options.outputMode, + emit: options.emit, + selected: options.generators, + options: options.generatorOptions?.get(name) ?? {}, + }; + let generated: GeneratedFile[]; + try { + // `docs` documents what `run` emits, so both run behind the same name and their + // files land together. A generator without a `docs` hook simply has no page. + if (options.emit.docs === true && generator.docs !== undefined) { + generated = [...generator.run(input), ...generator.docs(input)]; + documented = true; + } else { + generated = generator.run(input); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Generator "${name}" failed: ${message}`); + } + if ( + !Array.isArray(generated) || + generated.some( + (file) => + typeof file?.path !== 'string' || file.path === '' || typeof file.content !== 'string' + ) + ) { + throw new Error( + `Generator "${name}" failed: run() must return an array of { path, content } files.` + ); + } + for (const file of generated) { + const resolved = resolve(outputRoot, file.path); + if (resolved !== outputRoot && !resolved.startsWith(outputRoot + sep)) { + throw new Error( + `Generator "${name}" failed: file path escapes the output directory: ${file.path}` + ); + } + if (seen.has(resolved)) { + throw new Error(`Generator conflict: ${file.path} already emitted by an earlier generator`); + } + seen.add(resolved); + // Carry the resolved path forward so the write goes where the guard looked — + // a relative `file.path` would otherwise resolve against the cwd at write time. + files.push({ path: resolved, content: file.content }); + } + } + // Asking for documentation and getting none is worth saying: `zod` and the framework + // wrappers document nothing, so a selection of only those writes no page. + if (options.emit.docs === true && !documented) { + logger.warn( + `generate-client: docs is on, but no selected generator writes documentation (${options.generators.join(', ')}).\n` + ); + } + return files; +} + +/** + * A parameter name used in two locations of one operation — `id` in the path AND in the + * query, which OpenAPI permits. Every SDK still sends both, under their own wire names, but + * the languages that pass parameters as separate arguments have to rename the second one, so + * the publisher should hear about it once and can rename it in the description instead. + */ +function warnRepeatedParamNames(model: ApiModel): void { + for (const service of model.services) { + for (const op of service.operations) { + const seen = new Set(); + const repeated = new Set(); + for (const param of [ + ...op.pathParams, + ...op.queryParams, + ...op.headerParams, + ...op.cookieParams, + ]) { + if (seen.has(param.name)) repeated.add(param.name); + seen.add(param.name); + } + if (repeated.size === 0) continue; + logger.warn( + `generate-client: operation "${op.specName ?? op.name}" uses ${[...repeated] + .map((name) => `"${name}"`) + .join( + ', ' + )} in more than one parameter location. Every SDK sends both, and the SDKs whose methods take one argument per parameter give the later one a suffixed name — rename it in the description to choose the name yourself.\n` + ); + } + } +} + +/** + * An OpenAPI Overlay (1.0.0) adding per-operation `x-codeSamples`, collected from + * every selected generator that implements the `sample` hook; undefined when no + * generator contributed a sample. Docs tooling applies it to the description — + * generation stays side-effect-free on the source. + */ +function codeSamplesOverlay( + model: ApiModel, + emit: EmitOptions, + selected: string[], + registry: Map, + outputPath: string +): string | undefined { + const actions = []; + for (const op of allOperations(model.services)) { + const samples = selected + .map((name) => registry.get(name)?.sample?.(op, { model, emit, outputPath })) + .filter((sample): sample is CodeSample => sample !== undefined); + if (samples.length > 0) { + actions.push({ + target: `$.paths['${op.path.replaceAll("'", "''")}'].${op.method}`, + update: { 'x-codeSamples': samples }, + }); + } + } + if (actions.length === 0) return undefined; + return stringifyYaml({ + overlay: '1.0.0', + info: { title: `Code samples for ${model.title}`, version: model.version }, + actions, + }); +} + +export async function generateClient( + options: GenerateClientOptions +): Promise { + // A path segment that is literally "undefined"/"null" is the telltale of an + // interpolation bug in the caller (`\`${dir}/client.ts\`` with `dir` unset) — reject + // it instead of silently creating an `undefined/` directory. + if ( + options.output.split(/[\\/]/).some((segment) => segment === 'undefined' || segment === 'null') + ) { + throw new Error( + `output path "${options.output}" contains a literal "undefined" or "null" segment — this looks like an interpolation bug in the caller` + ); + } + // Setup is a LOCAL module (its code is baked into the generated client) — reject + // URL-ish specifiers upfront, before any spec loading, instead of failing later as + // an unreadable file path. Two+ letter scheme, so Windows drive paths don't match. + if (options.setup && /^[a-z][a-z0-9+.-]+:/i.test(options.setup)) { + throw new NotSupportedError( + `setup must be a local file path — remote setup modules are not supported (got: ${options.setup})` + ); + } + const outputPath = resolve(options.output); + const { document, version } = await loadSpec(options.api, options.config); + const normalized = + version === 'oas2' + ? // loadSpec types the parsed document as OAS3 for the common path; a detected + // swagger-2 document is re-viewed as raw data for normalization. + normalizeSwagger2(document as unknown as Record) + : document; + const model = buildApiModel(normalized); + warnRepeatedParamNames(model); + + // A publisher `--setup` module is read, validated, and transformed into the neutral setup + // expression baked into the client. Applied across all output modes by the emitter. + // 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'); + // 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); + setupBlock = bakeSetup(await readFile(setupPath, 'utf-8')); + } + + // Resolve the selection into a registry: built-in names load lazily, inline + // `customGenerators` register, and any other entry is imported as a plugin + // specifier (path/package). An empty list (e.g. `generators: []` in config, or + // no `--generator` flags) means "unspecified" — fall back to the default + // typescript client rather than emitting nothing. + const requested = options.generators?.length ? options.generators : ['typescript']; + const { selected, registry } = await resolveGenerators(requested, { + customGenerators: options.customGenerators, + configDir: options.configDir, + }); + + const emit: EmitOptions = { + serverUrl: options.serverUrl, + argsStyle: options.argsStyle, + errorMode: options.errorMode, + dateType: options.dateType, + mockData: options.mockData, + mockSeed: options.mockSeed, + queryKeyPrefix: options.queryKeyPrefix, + setup: setupBlock, + runtime: options.runtime, + importExt: options.importExt, + goPackage: options.goPackage, + pagination: options.pagination, + docs: options.docs, + docsFrontmatter: options.docsFrontmatter, + }; + // Fail fast on an incompatible selection (missing prerequisite, unsupported + // error-mode/date-type/runtime) before producing any file, and warn about options a + // selected generator can't apply. + validateSelection(selected, emit, registry, options.outputMode); + const generatorOptions = resolveGeneratorOptions(selected, registry, options.options); + const files = runGenerators(model, { + outputPath, + outputMode: options.outputMode ?? 'single', + emit, + generators: selected, + generatorOptions, + registry, + }); + + if (options.codeSamples === true) { + const overlay = codeSamplesOverlay(model, emit, selected, registry, outputPath); + if (overlay !== undefined) { + files.push({ path: outputPath.replace(/\.[^.]+$/, '.code-samples.yaml'), content: overlay }); + } + } + + const written: GenerateClientResult['files'] = []; + for (const file of files) { + await mkdir(dirname(file.path), { recursive: true }); + await writeFile(file.path, file.content, 'utf-8'); + written.push({ path: file.path, bytes: Buffer.byteLength(file.content, 'utf-8') }); + } + + return { + outputPath, + bytes: written.reduce((sum, file) => sum + file.bytes, 0), + files: written, + }; +} diff --git a/packages/client-generator/src/plugin.ts b/packages/client-generator/src/plugin.ts index 28b247cf73..9b45e84a27 100644 --- a/packages/client-generator/src/plugin.ts +++ b/packages/client-generator/src/plugin.ts @@ -7,17 +7,17 @@ // A custom generator is `(GeneratorInput) => GeneratedFile[]` plus a `name`; select it in // `generators` by name (inline via `customGenerators`) or by import specifier (path/package). It // receives the same spec-agnostic IR (`model`) the built-in generators consume, and may use the same -// TypeScript-emitting toolkit re-exported below, so a plugin is a first-class peer of `sdk`/`zod`/… +// TypeScript-emitting toolkit re-exported below, so a plugin is a first-class peer of `typescript`/`zod`/… // The generated client stays dependency-free: a plugin's output is its own file(s), and its runtime // libraries are peers of the consumer's app, never of the client. // // // my-generator.ts // import { defineGenerator } from '@redocly/client-generator'; -// // AST toolkit, when string-building isn't enough: -// // import { ts, printStatements } from '@redocly/client-generator/generate'; +// // TypeScript renderers, when a real type is needed rather than guessed text: +// // import { tsType } from '@redocly/client-generator/generate'; // export default defineGenerator({ // name: 'route-map', -// requires: ['sdk'], +// requires: ['typescript'], // run({ model, outputPath }) { // const routes = model.services.flatMap((s) => s.operations) // .map((op) => ` ${op.name}: '${op.method.toUpperCase()} ${op.path}',`).join('\n'); @@ -28,6 +28,8 @@ import type { CustomGenerator } from './generators/types.js'; +export { GENERATOR_VERSION } from './generators/compatibility.js'; + /** * Identity helper for authoring a custom generator with full type inference and one validation * choke-point. `export default defineGenerator({ name, run, … })`. Returns its argument unchanged. @@ -65,6 +67,6 @@ export type { ServiceModel, } from './intermediate-representation/model.js'; -// The TypeScript-emitting toolkit (`ts`, `printStatements`, `operationSignature`, …) is -// exported from `@redocly/client-generator/generate` — it loads `typescript`, which the -// runtime-only package root must not reach statically. +// 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. diff --git a/packages/client-generator/src/runtime-sources.ts b/packages/client-generator/src/runtime-sources.ts new file mode 100644 index 0000000000..4192e8391e --- /dev/null +++ b/packages/client-generator/src/runtime-sources.ts @@ -0,0 +1,12 @@ +// The public `@redocly/client-generator/runtime-sources` entry: the embedded-runtime +// source strings for the language generators. Ejected generator files import these +// instead of baking the runtime in, so embedded-runtime fixes still arrive via +// `npm update` and the ejected file stays small and readable. Pure strings — this +// entry's import graph must stay dependency-free (guarded like the root entry). + +export { GO_RUNTIME_SOURCE } from './emitters/go-runtime-sources.js'; +export { PHP_RUNTIME_SOURCE } from './emitters/php-runtime-sources.js'; +export { + PYTHON_RUNTIME_SOURCES, + type PythonRuntimeModuleName, +} from './emitters/python-runtime-sources.js'; diff --git a/packages/client-generator/src/runtime/__tests__/cli.test.ts b/packages/client-generator/src/runtime/__tests__/cli.test.ts new file mode 100644 index 0000000000..7485f1ec51 --- /dev/null +++ b/packages/client-generator/src/runtime/__tests__/cli.test.ts @@ -0,0 +1,768 @@ +import { + invokedName, + parseInvocation, + runCli, + type CliCommand, + type CliWiring, + type CommandContext, + type CustomCommand, +} from '../cli.js'; + +const LIST: CliCommand = { + group: 'orders', + name: 'listOrders', + summary: 'List orders.', + method: 'GET', + path: '/orders', + positionals: [], + flags: [ + { name: 'status', param: 'status', type: 'string', required: false, enum: ['open', 'closed'] }, + { name: 'limit', param: 'limit', type: 'number', required: false }, + { name: 'tag', param: 'tag', type: 'array', required: false }, + ], + paginated: true, +}; +const GET: CliCommand = { + group: 'orders', + name: 'getOrder', + method: 'GET', + path: '/orders/{orderId}', + positionals: [{ name: 'orderId', type: 'string' }], + flags: [], +}; +const CREATE: CliCommand = { + group: 'orders', + name: 'createOrder', + method: 'POST', + path: '/orders', + positionals: [], + flags: [], + body: { required: true }, + schemas: { request: { kind: 'object' } }, +}; +const PING: CliCommand = { name: 'ping', method: 'GET', path: '/ping', positionals: [], flags: [] }; +const COMMANDS = [LIST, GET, CREATE, PING]; + +describe('parseInvocation', () => { + it('routes group + name, coerces flag types, repeats arrays, accepts --flag=value', () => { + const parsed = parseInvocation(COMMANDS, [ + 'orders', + 'listOrders', + '--status', + 'open', + '--limit=10', + '--tag', + 'a', + '--tag', + 'b', + ]); + expect(parsed).toMatchObject({ + kind: 'run', + command: LIST, + params: { status: 'open', limit: 10, tag: ['a', 'b'] }, + }); + }); + + it('binds positionals in path order and routes untagged commands flat', () => { + expect(parseInvocation(COMMANDS, ['orders', 'getOrder', 'ord_1'])).toMatchObject({ + kind: 'run', + positionals: { orderId: 'ord_1' }, + }); + expect(parseInvocation(COMMANDS, ['ping'])).toMatchObject({ kind: 'run', command: PING }); + }); + + it('a name that is also a group: the untagged command wins, the tagged one keeps group help', () => { + const untagged: CliCommand = { ...PING, name: 'orders' }; + expect(parseInvocation([...COMMANDS, untagged], ['orders'])).toMatchObject({ + kind: 'run', + command: untagged, + }); + // Tagged elsewhere, the group still owns the bare word — `misc orders` runs the command. + const tagged: CliCommand = { ...PING, group: 'misc', name: 'orders' }; + expect(parseInvocation([...COMMANDS, tagged], ['orders'])).toMatchObject({ + kind: 'help', + topic: 'orders', + }); + expect(parseInvocation([...COMMANDS, tagged], ['misc', 'orders'])).toMatchObject({ + kind: 'run', + command: tagged, + }); + }); + + it('extracts global flags and leaves the body source raw', () => { + const parsed = parseInvocation(COMMANDS, [ + 'orders', + 'createOrder', + '--json', + '{"a":1}', + '--dry-run', + '--server-url', + 'http://x', + '--format', + 'ndjson', + ]); + expect(parsed).toMatchObject({ + kind: 'run', + globals: { json: '{"a":1}', dryRun: true, serverUrl: 'http://x', format: 'ndjson' }, + }); + }); + + it.each([ + [['nowhere'], /unknown command/i], + [['orders', 'nowhere'], /unknown command/i], + [['orders', 'listOrders', '--bogus', 'x'], /unknown flag/i], + [['orders', 'listOrders', '--limit', 'ten'], /expects a number/i], + [['orders', 'listOrders', '--status', 'stale'], /one of: open, closed/i], + [['orders', 'getOrder'], /missing required argument/i], + [['orders', 'getOrder', 'a', 'b'], /unexpected argument/i], + [['orders', 'getOrder', 'a', '--json', '{}'], /does not accept a request body/i], + [['orders', 'createOrder'], /requires a request body/i], + [['orders', 'listOrders', '--format', 'xml'], /one of: json, ndjson/i], + ])('usage error for %j', (argv, message) => { + expect(parseInvocation(COMMANDS, argv as string[])).toMatchObject({ + kind: 'usage-error', + message: expect.stringMatching(message), + }); + }); + + it('recognizes help at root, group, and command level, and the schema pseudo-command', () => { + expect(parseInvocation(COMMANDS, [])).toMatchObject({ kind: 'help' }); + expect(parseInvocation(COMMANDS, ['--help'])).toMatchObject({ kind: 'help' }); + expect(parseInvocation(COMMANDS, ['orders', '--help'])).toMatchObject({ + kind: 'help', + topic: 'orders', + }); + expect(parseInvocation(COMMANDS, ['orders', 'listOrders', '--help'])).toMatchObject({ + kind: 'help', + topic: LIST, + }); + expect(parseInvocation(COMMANDS, ['schema', 'createOrder'])).toMatchObject({ + kind: 'schema', + command: CREATE, + }); + expect(parseInvocation(COMMANDS, ['schema', 'nowhere'])).toMatchObject({ + kind: 'usage-error', + }); + }); +}); + +type FakeCall = { name: string; variables: unknown }; + +function fakeWiring(overrides: Partial & { results?: Record } = {}) { + const calls: FakeCall[] = []; + const configured: Record[] = []; + const out: string[] = []; + const err: string[] = []; + const { results = {}, ...rest } = overrides; + const client: Record = {}; + for (const command of COMMANDS) { + const method = async (variables: unknown) => { + calls.push({ name: command.name, variables }); + const result = results[command.name]; + if (result instanceof Error) throw result; + return result; + }; + client[command.name] = Object.assign(method, { + pages: async function* (variables: unknown) { + calls.push({ name: `${command.name}.pages`, variables }); + yield { items: [1] }; + yield { items: [2] }; + }, + }); + } + const wiring: CliWiring = { + name: 'cafe', + envPrefix: 'CAFE', + client, + configure: (config) => configured.push(config as Record), + schemes: [{ key: 'BearerAuth', kind: 'bearer' }], + env: {}, + stdout: (line) => out.push(line), + stderr: (line) => err.push(line), + ...rest, + }; + return { wiring, calls, configured, out, err }; +} + +describe('custom commands (composition)', () => { + const whoami = (received: CommandContext[]): CustomCommand => ({ + name: 'whoami', + summary: 'Print the current identity.', + flags: [{ name: 'verbose', param: 'verbose', type: 'boolean', required: false }], + handler: (context) => { + received.push(context); + context.wiring.stdout('me'); + return 0; + }, + }); + + it('dispatches a handler with parsed inputs and the wiring, and uses its exit code', async () => { + const received: CommandContext[] = []; + const { wiring, out } = fakeWiring(); + const code = await runCli([...COMMANDS, whoami(received)], wiring, ['whoami', '--verbose']); + expect(code).toBe(0); + expect(out).toEqual(['me']); + expect(received[0].params).toEqual({ verbose: true }); + expect(received[0].wiring.name).toBe('cafe'); + }); + + it('lists a custom command in help and its declared contract in schema', async () => { + const { wiring, out } = fakeWiring(); + await runCli([...COMMANDS, whoami([])], wiring, ['--help']); + expect(out.join('\n')).toContain('whoami Print the current identity.'); + + const schema = fakeWiring(); + await runCli([...COMMANDS, whoami([])], schema.wiring, ['schema', 'whoami']); + const contract = JSON.parse(schema.out.join('\n')); + expect(contract.operationId).toBe('whoami'); + expect(contract.parameters.query).toEqual([ + expect.objectContaining({ name: 'verbose', type: 'boolean' }), + ]); + expect(contract.request).toBeUndefined(); + }); + + it('a thrown handler exits 1 with the standard error JSON', async () => { + const boom: CustomCommand = { + name: 'boom', + handler: () => { + throw new Error('handler exploded'); + }, + }; + const { wiring, err } = fakeWiring(); + const code = await runCli([...COMMANDS, boom], wiring, ['boom']); + expect(code).toBe(1); + expect(JSON.parse(err.join('')).error.message).toContain('handler exploded'); + }); + + it('rejects a custom command whose name collides with a generated one', async () => { + const shadow: CustomCommand = { name: 'ping', handler: () => 0 }; + const { wiring, err } = fakeWiring(); + const code = await runCli([...COMMANDS, shadow], wiring, ['ping']); + // Silently shadowing an operation is how an operator debugs the wrong thing. + expect(code).toBe(4); + expect(JSON.parse(err.join('')).error.message).toContain('ping'); + }); +}); + +describe('multi-source runCli (one binary, several APIs)', () => { + function sources(overrides: { rootCommands?: CustomCommand[] } = {}) { + const main = fakeWiring(); + const syncer = fakeWiring({ envPrefix: 'CAFE_SYNCER' }); + const root = fakeWiring(); + return { + main, + syncer, + root, + list: [ + ...(overrides.rootCommands + ? [{ commands: overrides.rootCommands, wiring: root.wiring }] + : []), + { namespace: 'main', commands: COMMANDS, wiring: main.wiring }, + // The same operationIds again: collisions across descriptions are the normal case. + { namespace: 'syncer', commands: COMMANDS, wiring: syncer.wiring }, + ], + }; + } + + it('routes the first token to its source, so colliding operationIds are different commands', async () => { + const context = sources(); + const code = await runCli(context.list, ['syncer', 'orders', 'getOrder', 'ord_9']); + expect(code).toBe(0); + expect(context.syncer.calls).toEqual([ + { name: 'getOrder', variables: { path: { orderId: 'ord_9' } } }, + ]); + expect(context.main.calls).toEqual([]); + }); + + it('a source without wiring inherits the first wired source, as the docs example relies on', async () => { + const seen: string[] = []; + const login: CustomCommand = { + name: 'login', + handler: (context) => { + seen.push(context.wiring.name); + context.wiring.stdout('ok'); + return 0; + }, + }; + const context = sources(); + // The documented shape: `{ commands: [login] }` — no wiring at all. + const code = await runCli([{ commands: [login] }, ...context.list], ['login']); + expect(code).toBe(0); + // The handler ran with the first wired source's identity, and its stdout. + expect(seen).toEqual(['cafe']); + expect(context.main.out).toEqual(['ok']); + }); + + it('a namespace-less source puts its commands at the root', async () => { + const login: CustomCommand = { + name: 'login', + handler: (context) => { + context.wiring.stdout('logged in'); + return 0; + }, + }; + const context = sources({ rootCommands: [login] }); + const code = await runCli(context.list, ['login']); + expect(code).toBe(0); + expect(context.root.out).toEqual(['logged in']); + }); + + it('top-level help lists the namespaces; namespace help lists that API alone', async () => { + const context = sources(); + await runCli(context.list, ['--help']); + const help = context.main.out.join('\n'); + expect(help).toContain('main'); + expect(help).toContain('syncer'); + + const scoped = sources(); + await runCli(scoped.list, ['syncer', '--help']); + expect(scoped.syncer.out.join('\n')).toContain('orders'); + }); + + it('an unknown first token is a usage error naming the namespaces', async () => { + const context = sources(); + const code = await runCli(context.list, ['nowhere', 'getOrder']); + expect(code).toBe(4); + const message = JSON.parse(context.main.err.join('')).error.message; + expect(message).toContain('main'); + expect(message).toContain('syncer'); + }); +}); + +describe('wiring.envPrefix (what a composed entry sets per api)', () => { + it('drives the credential variables without changing the displayed name', async () => { + const { wiring, out } = fakeWiring({ envPrefix: 'CAFE_SHOP' }); + await runCli(COMMANDS, wiring, ['--help']); + const help = out.join('\n'); + expect(help).toContain('Usage: cafe'); + expect(help).toContain('CAFE_SHOP_TOKEN'); + expect(help).not.toContain('CAFE_TOKEN'); + }); + + it('reads credentials under the override', async () => { + const { wiring, calls, configured } = fakeWiring({ + envPrefix: 'CAFE_SHOP', + env: { CAFE_SHOP_TOKEN: 'tok' }, + results: { getOrder: {} }, + }); + await runCli(COMMANDS, wiring, ['orders', 'getOrder', 'ord_1']); + expect(calls).toHaveLength(1); + expect( + configured.some((config) => (config.auth as { bearer?: string })?.bearer === 'tok') + ).toBe(true); + }); +}); + +describe('dry-run redaction covers every credential form', () => { + it('redacts a basic Authorization header, which carries the base64 form of the secret', async () => { + // Substring-matching the RAW password against header values misses the header the + // client actually sends (`Basic ${base64(user:pass)}`) — printing decodable + // credentials in the output support engineers paste into tickets. + const { wiring, out } = fakeWiring({ + schemes: [{ key: 'BasicAuth', kind: 'basic' }], + env: { CAFE_USERNAME: 'sam', CAFE_PASSWORD: 'hunter2' }, + configure: () => undefined, + }); + // The dry-run stub fetch is installed via configure; emulate the client sending the + // encoded header by calling the captured fetch ourselves. + const configured: Record[] = []; + wiring.configure = (config) => configured.push(config); + wiring.client = { + ping: async () => { + const stub = configured.find((config) => typeof config.fetch === 'function'); + const fetchStub = stub?.fetch as (url: string, init: unknown) => Promise; + return fetchStub('/ping', { + method: 'GET', + headers: { Authorization: `Basic ${btoa('sam:hunter2')}` }, + }); + }, + }; + const code = await runCli(COMMANDS, wiring, ['ping', '--dry-run']); + expect(code).toBe(0); + const captured = out.join('\n'); + expect(captured).not.toContain(btoa('sam:hunter2')); + expect(captured).toContain('***'); + }); +}); + +describe('schema is the complete contract for one command', () => { + it('reports parameters, body, schemas, and the behavior flags', async () => { + const { wiring, out } = fakeWiring(); + const code = await runCli(COMMANDS, wiring, ['schema', 'listOrders']); + expect(code).toBe(0); + const contract = JSON.parse(out.join('\n')); + + // An agent reading only this must be able to construct a valid invocation, so the + // parameters have to be here — 'GET' operations have nothing else. + expect(contract.operationId).toBe('listOrders'); + expect(contract.method).toBe('GET'); + expect(contract.path).toBe('/orders'); + expect(contract.parameters.query).toContainEqual( + expect.objectContaining({ name: 'status', param: 'status', type: 'string', required: false }) + ); + expect(contract.paginated).toBe(true); + }); + + it('reports a path parameter with its type, which the usage line already knows', async () => { + const { wiring, out } = fakeWiring(); + await runCli(COMMANDS, wiring, ['schema', 'getOrder']); + const contract = JSON.parse(out.join('\n')); + expect(contract.parameters.path).toEqual([ + expect.objectContaining({ name: 'orderId', type: 'string', required: true }), + ]); + }); + + it('keeps the request and response schemas it already reported', async () => { + const { wiring, out } = fakeWiring(); + await runCli(COMMANDS, wiring, ['schema', 'createOrder']); + const contract = JSON.parse(out.join('\n')); + expect(contract.request).toBeDefined(); + expect(contract.body).toEqual({ required: true }); + }); +}); + +describe('credential flags follow the declared schemes', () => { + const noBearer = [ + { key: 'BasicAuth', kind: 'basic' as const }, + { key: 'InternalToken', kind: 'apiKey' as const }, + ]; + + it('omits --token from help when the description declares no bearer scheme', async () => { + const { wiring, out } = fakeWiring({ schemes: noBearer }); + await runCli(COMMANDS, wiring, ['--help']); + const help = out.join('\n'); + expect(help).not.toContain('--token'); + // The environment block follows the same rule: only what this API can use. (The + // apiKey variable is named after its scheme, so match the bearer one exactly.) + expect(help).not.toContain('CAFE_TOKEN'); + expect(help).toContain('CAFE_USERNAME'); + expect(help).toContain('CAFE_API_KEY_INTERNAL_TOKEN'); + }); + + it('keeps --token when a bearer scheme is declared', async () => { + const { wiring, out } = fakeWiring(); + await runCli(COMMANDS, wiring, ['--help']); + expect(out.join('\n')).toContain('--token '); + }); + + it('rejects --token instead of silently discarding it, naming what the API accepts', async () => { + const { wiring, err } = fakeWiring({ schemes: noBearer }); + const code = await runCli(COMMANDS, wiring, [ + 'orders', + 'getOrder', + 'ord_1', + '--token', + 'secret', + ]); + // Exit 4 is the usage-error contract; a dropped credential reads as "my token is + // wrong" and costs a debugging session. + expect(code).toBe(4); + const message = JSON.parse(err.join('')).error.message; + expect(message).toContain('--token'); + expect(message).toContain('BasicAuth'); + expect(message).toContain('InternalToken'); + expect(message).not.toContain('secret'); + }); +}); + +describe('runCli', () => { + it('dispatches inputs grouped by layer and pretty-prints the JSON result', async () => { + const { wiring, calls, out } = fakeWiring({ results: { getOrder: { id: 'ord_1' } } }); + const code = await runCli(COMMANDS, wiring, ['orders', 'getOrder', 'ord_1']); + expect(code).toBe(0); + expect(calls).toEqual([{ name: 'getOrder', variables: { path: { orderId: 'ord_1' } } }]); + expect(JSON.parse(out.join('\n'))).toEqual({ id: 'ord_1' }); + }); + + it('passes query params under `query` and prints nothing for void results', async () => { + const { wiring, calls, out } = fakeWiring(); + const code = await runCli(COMMANDS, wiring, ['orders', 'listOrders', '--status', 'open']); + expect(code).toBe(0); + expect(calls[0]).toEqual({ name: 'listOrders', variables: { query: { status: 'open' } } }); + expect(out).toEqual([]); + }); + + it('loads --json bodies inline, from @file, and from @- (stdin)', async () => { + const { wiring, calls } = fakeWiring({ + readFile: () => '{"from":"file"}', + stdin: () => '{"from":"stdin"}', + }); + await runCli(COMMANDS, wiring, ['orders', 'createOrder', '--json', '{"from":"inline"}']); + await runCli(COMMANDS, wiring, ['orders', 'createOrder', '--json', '@body.json']); + await runCli(COMMANDS, wiring, ['orders', 'createOrder', '--json', '@-']); + expect(calls.map((call) => (call.variables as { body: unknown }).body)).toEqual([ + { from: 'inline' }, + { from: 'file' }, + { from: 'stdin' }, + ]); + }); + + it('malformed --json is a usage error: JSON error object on stderr, exit 4, no dispatch', async () => { + const { wiring, calls, err } = fakeWiring(); + const code = await runCli(COMMANDS, wiring, ['orders', 'createOrder', '--json', '{nope']); + expect(code).toBe(4); + expect(calls).toEqual([]); + expect(JSON.parse(err.join('\n')).error.code).toBe(4); + }); + + it.each([ + [Object.assign(new Error('boom'), { name: 'ApiError', status: 500 }), 1], + [Object.assign(new Error('nope'), { name: 'ApiError', status: 401 }), 2], + [Object.assign(new Error('bad'), { name: 'ZodValidationError' }), 3], + [new Error('plain'), 1], + ])('maps thrown %o to exit %i with a JSON error on stderr', async (error, expected) => { + const { wiring, err } = fakeWiring({ results: { ping: error } }); + const code = await runCli(COMMANDS, wiring, ['ping']); + expect(code).toBe(expected); + const printed = JSON.parse(err.join('\n')).error; + expect(printed.code).toBe(expected); + expect(printed.message).toBe(error.message); + }); + + it('resolves bearer auth from _TOKEN; --token wins over env', async () => { + const { wiring, configured } = fakeWiring({ env: { CAFE_TOKEN: 'from-env' } }); + await runCli(COMMANDS, wiring, ['ping']); + expect(configured[0]).toEqual({ auth: { bearer: 'from-env' } }); + + const flagged = fakeWiring({ env: { CAFE_TOKEN: 'from-env' } }); + await runCli(COMMANDS, flagged.wiring, ['ping', '--token', 'from-flag']); + expect(flagged.configured[0]).toEqual({ auth: { bearer: 'from-flag' } }); + }); + + it('resolves basic and apiKey credentials from prefixed env vars', async () => { + const { wiring, configured } = fakeWiring({ + schemes: [ + { key: 'BasicAuth', kind: 'basic' }, + { key: 'ApiKeyAuth', kind: 'apiKey' }, + ], + env: { CAFE_USERNAME: 'u', CAFE_PASSWORD: 'p', CAFE_API_KEY_API_KEY_AUTH: 'k' }, + }); + await runCli(COMMANDS, wiring, ['ping']); + expect(configured[0]).toEqual({ + auth: { basic: { username: 'u', password: 'p' }, apiKey: { ApiKeyAuth: 'k' } }, + }); + }); + + it('--server-url reconfigures the client', async () => { + const { wiring, configured } = fakeWiring(); + await runCli(COMMANDS, wiring, ['ping', '--server-url', 'http://other']); + expect(configured).toContainEqual({ serverUrl: 'http://other' }); + }); + + it('--dry-run captures the prepared request via injected fetch, redacts credentials, sends nothing', async () => { + const { wiring, configured, out } = fakeWiring({ env: { CAFE_TOKEN: 'secret-token' } }); + // The generated client would call the injected fetch; emulate that with a client + // whose method invokes whatever fetch was configured, like the real runtime does. + let injectedFetch: ((url: string, init: RequestInit) => Promise) | undefined; + wiring.configure = (config) => { + configured.push(config as Record); + const candidate = (config as { fetch?: typeof injectedFetch }).fetch; + if (candidate) injectedFetch = candidate; + }; + (wiring.client as Record).ping = async () => { + await injectedFetch?.('http://api/ping', { + method: 'GET', + headers: { Authorization: 'Bearer secret-token' }, + }); + return { ok: true }; + }; + const code = await runCli(COMMANDS, wiring, ['ping', '--dry-run']); + expect(code).toBe(0); + const printed = JSON.parse(out.join('\n')); + expect(printed).toEqual({ + url: 'http://api/ping', + method: 'GET', + headers: { Authorization: '***' }, + }); + }); + + it('--page-all streams one JSON page per line through .pages()', async () => { + const { wiring, out } = fakeWiring(); + const code = await runCli(COMMANDS, wiring, ['orders', 'listOrders', '--page-all']); + expect(code).toBe(0); + expect(out.map((line) => JSON.parse(line))).toEqual([{ items: [1] }, { items: [2] }]); + }); + + it('--page-all on a non-paginated operation is a usage error', async () => { + const { wiring } = fakeWiring(); + expect(await runCli(COMMANDS, wiring, ['ping', '--page-all'])).toBe(4); + }); + + it('sse results stream as NDJSON events', async () => { + const events = [ + { event: 'tick', data: 1 }, + { event: 'tick', data: 2 }, + ]; + const sseCommands = [{ ...PING, name: 'streamEvents', sse: true }]; + const { wiring, out } = fakeWiring(); + (wiring.client as Record).streamEvents = async function* () { + yield* events; + }; + const code = await runCli(sseCommands, wiring, ['streamEvents']); + expect(code).toBe(0); + expect(out.map((line) => JSON.parse(line))).toEqual(events); + }); + + it('--dry-run on an sse command drains the lazy stream so the request is captured', async () => { + const sseCommands = [{ ...PING, name: 'streamEvents', sse: true }]; + const { wiring, configured, out } = fakeWiring(); + let injectedFetch: ((url: string, init: RequestInit) => Promise) | undefined; + wiring.configure = (config) => { + configured.push(config as Record); + const candidate = (config as { fetch?: typeof injectedFetch }).fetch; + if (candidate) injectedFetch = candidate; + }; + // Like the real runtime, the stream is lazy: nothing happens until the first pull, + // and the stubbed dry-run response carries no events to yield. + (wiring.client as Record).streamEvents = async function* () { + const stubbed = await injectedFetch?.('http://api/events', { method: 'GET', headers: {} }); + if (stubbed === undefined) yield { event: 'tick', data: 1 }; + }; + const code = await runCli(sseCommands, wiring, ['streamEvents', '--dry-run']); + expect(code).toBe(0); + expect(JSON.parse(out.join('\n'))).toEqual({ + url: 'http://api/events', + method: 'GET', + headers: {}, + }); + }); + + it('blob results require --output and print a byte receipt', async () => { + const blobCommands = [{ ...PING, name: 'downloadReport', blob: true }]; + const writes: Array<{ path: string; bytes: number }> = []; + const { wiring, out } = fakeWiring({ + writeFile: (path, data) => writes.push({ path, bytes: data.length }), + }); + (wiring.client as Record).downloadReport = async () => + new Blob([new Uint8Array([1, 2, 3])]); + expect(await runCli(blobCommands, wiring, ['downloadReport'])).toBe(4); + const code = await runCli(blobCommands, wiring, ['downloadReport', '--output', 'report.bin']); + expect(code).toBe(0); + expect(writes).toEqual([{ path: 'report.bin', bytes: 3 }]); + expect(JSON.parse(out.join('\n'))).toEqual({ saved: 'report.bin', bytes: 3 }); + }); + + it('schema prints the stored request/response schemas inside the contract', async () => { + const { wiring, out } = fakeWiring(); + const code = await runCli(COMMANDS, wiring, ['schema', 'createOrder']); + expect(code).toBe(0); + // The schemas keep their own keys; the contract adds the rest around them. + expect(JSON.parse(out.join('\n'))).toMatchObject({ request: { kind: 'object' } }); + }); + + it('help renders groups at the root, commands per group, and flags per command', async () => { + const root = fakeWiring(); + expect(await runCli(COMMANDS, root.wiring, ['--help'])).toBe(0); + const rootText = root.out.join('\n'); + expect(rootText).toContain('orders'); + expect(rootText).toContain('ping'); + + const group = fakeWiring(); + await runCli(COMMANDS, group.wiring, ['orders', '--help']); + expect(group.out.join('\n')).toContain('listOrders'); + + const command = fakeWiring(); + await runCli(COMMANDS, command.wiring, ['orders', 'listOrders', '--help']); + const commandText = command.out.join('\n'); + expect(commandText).toContain('--status'); + expect(commandText).toContain('open, closed'); + expect(commandText).toContain('List orders.'); + }); +}); + +describe('help output', () => { + const MULTILINE: CliCommand = { + group: 'Some multi-word tag', + name: 'listThings', + summary: 'List things.', + method: 'GET', + path: '/things', + positionals: [], + flags: [ + { + name: 'cursor', + param: 'cursor', + type: 'string', + required: false, + description: + 'Cursor value for pagination.\nReturns items starting at this cursor.\n\nSee the guide.', + }, + ], + }; + + async function help(argv: string[], commands = COMMANDS) { + const { wiring, out } = fakeWiring(); + const code = await runCli(commands, wiring, argv); + return { code, text: out.join('\n') }; + } + + it('lists every global flag and the credential env vars', async () => { + const { code, text } = await help(['--help']); + expect(code).toBe(0); + expect(text).toContain('Global flags:'); + for (const flag of [ + '--server-url', + '--format', + '--dry-run', + '--page-all', + '--output', + '--token', + '--json', + ]) { + expect(text).toContain(flag); + } + // The env vars are how credentials actually get in. + expect(text).toContain('_TOKEN'); + }); + + it('points at the grouped form in the footer, since a bare command fails for grouped APIs', async () => { + const { text } = await help(['--help']); + expect(text).toContain(' --help'); + }); + + it('collapses a multiline flag description onto one line', async () => { + const { text } = await help(['some-multi-word-tag', 'listThings', '--help'], [MULTILINE]); + const cursorLine = text.split('\n').find((line) => line.includes('--cursor')); + expect(cursorLine).toContain( + 'Cursor value for pagination. Returns items starting at this cursor. See the guide.' + ); + expect(text.split('\n').filter((line) => line.startsWith('Returns items'))).toEqual([]); + }); + + it('addresses a multi-word tag by its kebab slug while showing the original title', async () => { + const { code, text } = await help(['--help'], [MULTILINE]); + expect(code).toBe(0); + // Typed without quoting… + expect(text).toContain('some-multi-word-tag'); + // …but the human name is still shown. + expect(text).toContain('Some multi-word tag'); + expect(parseInvocation([MULTILINE], ['some-multi-word-tag', 'listThings'])).toMatchObject({ + kind: 'run', + command: MULTILINE, + }); + }); + + it('resolves a bare operationId to its grouped command', () => { + expect(parseInvocation(COMMANDS, ['getOrder', 'ord_1'])).toMatchObject({ + kind: 'run', + command: GET, + }); + }); +}); + +describe('invokedName', () => { + it('names the command the CLI was invoked as, not the script file', () => { + // A global install: `argv[1]` IS the bin, so its basename is what the user typed. + expect(invokedName('/usr/local/bin/cafe', 'client')).toBe('cafe'); + expect(invokedName('/usr/local/bin/mycafe', 'client')).toBe('mycafe'); + // A Windows shim, a `node dist/cafe.cli.js`, and a `tsx client.cli.ts` run all pass the + // script path — printing that would name a command nobody can type. + expect(invokedName('C:\\project\\dist\\cafe.cli.js', 'client')).toBe('cafe'); + expect(invokedName('/project/src/client.cli.ts', 'client')).toBe('client'); + expect(invokedName('/project/bin/cafe.cmd', 'client')).toBe('cafe'); + expect(invokedName('/project/dist/cafe.mjs', 'client')).toBe('cafe'); + // Nothing to read, or nothing left after trimming: the generated name stands in. + expect(invokedName(undefined, 'client')).toBe('client'); + expect(invokedName('/project/.js', 'client')).toBe('client'); + }); +}); diff --git a/packages/client-generator/src/runtime/__tests__/create-client.test.ts b/packages/client-generator/src/runtime/__tests__/create-client.test.ts index ff663c8683..cadc89a3a8 100644 --- a/packages/client-generator/src/runtime/__tests__/create-client.test.ts +++ b/packages/client-generator/src/runtime/__tests__/create-client.test.ts @@ -77,17 +77,21 @@ const OPS = { interface Ops { getOrder: { - args: { orderId: string; params?: { expand?: string }; headers?: Record }; + args: { + path: { orderId: string }; + query?: { expand?: string }; + headers?: Record; + }; result: { id: string }; }; createPet: { args: { body: { name: string } }; result: { id: string } }; - listRaw: { args: { params?: { filter?: string[] } }; result: string }; - search: { args: { params?: { ids?: string[]; path?: string } }; result: string }; + listRaw: { args: { query?: { filter?: string[] } }; result: string }; + search: { args: { query?: { ids?: string[]; path?: string } }; result: string }; secured: { args: Record; result: string }; stream: { args: { body?: { topic: string } }; result: { n: number }; kind: 'sse' }; streamPlain: { args: Record; result: string; kind: 'sse' }; listOrders: { - args: { params?: { cursor?: string; limit?: number } }; + args: { query?: { cursor?: string; limit?: number } }; result: { orders: Array<{ id: string }>; nextCursor?: string }; item: { id: string }; }; @@ -146,7 +150,7 @@ describe('createClientCore', () => { ]); const client = createClientCore<{ listRepos: { - args: { params?: { per_page?: number } }; + args: { query?: { per_page?: number } }; result: string[]; item: string; }; @@ -157,7 +161,7 @@ describe('createClientCore', () => { { paginate: { pages: paginatePages, items: paginateItems, pagesByLink, itemsByLink } } ); const seen: string[] = []; - for await (const repo of client.listRepos.items({ params: { per_page: 1 } })) seen.push(repo); + for await (const repo of client.listRepos.items({ query: { per_page: 1 } })) seen.push(repo); expect(seen).toEqual(['a', 'b']); expect(calls[0].url).toBe('https://x/repos?per_page=1'); // Page 2 rides the Link target's query params through the same declared endpoint. @@ -173,7 +177,7 @@ describe('createClientCore', () => { jsonOk(['x']), ]); const client = createClientCore(OPS, { serverUrl: 'https://x', fetch: fetchImpl }); - await client.getOrder({ orderId: 'o1' }); + await client.getOrder({ path: { orderId: 'o1' } }); expect((calls[0].init.headers as Record).Accept).toBe('application/json'); await client.listRaw({}); expect((calls[1].init.headers as Record).Accept).toBe('text/*'); @@ -182,10 +186,38 @@ describe('createClientCore', () => { expect((calls[2].init.headers as Record).Accept).toBe('application/json'); }); + it('a flat client still takes namespaced args for an operation marked grouped', async () => { + // A merged call cannot carry one name for two layers, so the generator marks that + // operation `argsStyle: 'grouped'` and types it that way — the runtime must agree. + const ops = { + getThing: { + id: 'getThing', + method: 'GET', + path: '/things/{id}', + params: [ + { name: 'id', in: 'path' as const }, + { name: 'id', in: 'query' as const }, + ], + argsStyle: 'grouped' as const, + }, + }; + const { calls, fetchImpl } = spy([jsonOk({ ok: true })]); + const client = createClientCore<{ + getThing: { args: Record; result: unknown }; + }>(ops, { + serverUrl: 'https://x', + argsStyle: 'flat', + fetch: fetchImpl, + }); + await client.getThing({ path: { id: 'p1' }, query: { id: 7 } }); + // Both values reach the wire, each in its own place. + expect(calls[0].url).toBe('https://x/things/p1?id=7'); + }); + it('rejects an unknown top-level argument key (flat-style shape passed to a grouped call)', async () => { const client = createClientCore(OPS, { serverUrl: 'https://x' }); - await expect(client.getOrder({ orderId: 'o1', limit: 10 } as never)).rejects.toThrow( - /Unknown argument "limit" for operation "getOrder".*params/ + await expect(client.getOrder({ path: { orderId: 'o1' }, limit: 10 } as never)).rejects.toThrow( + /Unknown argument "limit" for operation "getOrder".*grouped by layer/ ); }); @@ -196,8 +228,8 @@ describe('createClientCore', () => { expect( await getOrder({ - orderId: 'a/b', - params: { expand: 'items' }, + path: { orderId: 'a/b' }, + query: { expand: 'items' }, headers: { 'X-Trace': 7, 'X-Skip': null }, }) ).toEqual({ id: 'o1' }); @@ -243,7 +275,7 @@ describe('createClientCore', () => { ]); const client = createClientCore(OPS, { serverUrl: 'https://x', fetch: fetchImpl }); - expect(await client.listRaw({ params: { filter: ['a', 'b'] } })).toBe('plain'); + expect(await client.listRaw({ query: { filter: ['a', 'b'] } })).toBe('plain'); expect(calls[0].url).toBe('https://x/raw?filter=a|b'); // parseAs overrides the descriptor's kind at runtime. @@ -253,7 +285,7 @@ describe('createClientCore', () => { it('resolves OpenAPI style defaults: explode:false alone comma-joins, allowReserved alone skips encoding', async () => { const { calls, fetchImpl } = spy([jsonOk('s')]); const client = createClientCore(OPS, { serverUrl: 'https://x', fetch: fetchImpl }); - await client.search({ params: { ids: ['a', 'b'], path: 'a/b' } }); + await client.search({ query: { ids: ['a', 'b'], path: 'a/b' } }); expect(calls[0].url).toBe('https://x/search?ids=a,b&path=a/b'); }); @@ -269,7 +301,7 @@ describe('createClientCore', () => { { onRequest: () => {} }, // no onError — skipped by the error chain { onError: (e) => new Error(`wrapped:${(e as { status: number }).status}`) } ); - await expect(client.getOrder({ orderId: '1' })).rejects.toThrow('wrapped:500'); + await expect(client.getOrder({ path: { orderId: '1' } })).rejects.toThrow('wrapped:500'); }); it('result mode: non-ok returns { error }, ok returns { data } — without throwing', async () => { @@ -285,13 +317,15 @@ describe('createClientCore', () => { serverUrl: 'https://x', errorMode: 'result', }); - const bad = (await client.getOrder({ orderId: '1' })) as unknown as { + const bad = (await client.getOrder({ path: { orderId: '1' } })) as unknown as { error: { title: string }; response: Response; }; expect(bad.error).toEqual({ title: 'x' }); expect(bad.response.status).toBe(500); - const good = (await client.getOrder({ orderId: '1' })) as unknown as { data: { id: string } }; + const good = (await client.getOrder({ path: { orderId: '1' } })) as unknown as { + data: { id: string }; + }; expect(good.data).toEqual({ id: 'ok' }); }); @@ -319,7 +353,7 @@ describe('createClientCore', () => { expect(calls[1].url).toContain('sig=v'); // Ops without security skip resolveAuth entirely. - await client.getOrder({ orderId: '1' }); + await client.getOrder({ path: { orderId: '1' } }); expect((calls[2].init.headers as Record).Authorization).toBeUndefined(); }); @@ -377,7 +411,7 @@ describe('createClientCore', () => { // Caller overrides the explicit header-param slot too. await client.getOrder( - { orderId: '1', headers: { 'X-Trace': 'from-args' } }, + { path: { orderId: '1' }, headers: { 'X-Trace': 'from-args' } }, { headers: { 'X-Trace': 'from-caller' } } ); expect((calls[1].init.headers as Record)['X-Trace']).toBe('from-caller'); @@ -392,7 +426,9 @@ describe('createClientCore', () => { ]); const client = createClientCore(OPS, { fetch: fetchImpl, serverUrl: 'https://x' }); client.configure({ errorMode: 'result' }); - await expect(client.getOrder({ orderId: '1' })).rejects.toMatchObject({ status: 500 }); + await expect(client.getOrder({ path: { orderId: '1' } })).rejects.toMatchObject({ + status: 500, + }); }); it('sse ops dispatch to the sse capability (with prepared url + body); absent capability throws sync', async () => { @@ -460,7 +496,7 @@ describe('createClientCore', () => { expect((client.getOrder as unknown as Record).pages).toBeUndefined(); expect((client.getOrder as unknown as Record).items).toBeUndefined(); - const args = { params: { limit: 2 } }; + const args = { query: { limit: 2 } }; const init = { headers: { 'X-Trace': '1' } }; const yielded = []; for await (const page of client.listOrders.pages(args, init)) yielded.push(page); @@ -469,7 +505,9 @@ describe('createClientCore', () => { expect(seen[0]).toEqual(['pages', OPS.listOrders.pagination, args, init]); for await (const item of client.listOrders.items()) expect(item).toEqual({ id: 'o9' }); - expect(seen[1]).toEqual(['items', 'cursor', undefined, undefined]); // bare call: no args/init + // The iterators normalize their inputs before handing them over, so an argument-less + // call reaches the capability as an empty input rather than `undefined`. + expect(seen[1]).toEqual(['items', 'cursor', {}, undefined]); // bare call: no args/init }); it('result mode: .pages/.items iterate RAW pages (the envelope is unwrapped before the pointers)', async () => { @@ -586,7 +624,7 @@ describe('createClientCore', () => { // The merged serverUrl is actually used. const { calls, fetchImpl } = spy([jsonOk({ id: '1' })]); client.configure({ fetch: fetchImpl }); - await client.getOrder({ orderId: '1' }); + await client.getOrder({ path: { orderId: '1' } }); expect(calls[0].url).toBe('https://later/orders/1'); }); @@ -601,7 +639,7 @@ describe('createClientCore', () => { const { calls, fetchImpl } = spy([jsonOk({ id: '1' })]); const client = createClientCore(OPS); client.configure({ fetch: fetchImpl }); - await client.getOrder({ orderId: '1' }); + await client.getOrder({ path: { orderId: '1' } }); expect(calls[0].url).toBe('/orders/1'); }); diff --git a/packages/client-generator/src/runtime/__tests__/paginate.test.ts b/packages/client-generator/src/runtime/__tests__/paginate.test.ts index f4a5346afb..08202399f3 100644 --- a/packages/client-generator/src/runtime/__tests__/paginate.test.ts +++ b/packages/client-generator/src/runtime/__tests__/paginate.test.ts @@ -18,7 +18,7 @@ function stub(data: unknown[]) { return data[calls.length - 1]; }; const sentParams = (name: string) => - calls.map((c) => (c.args?.params as Record | undefined)?.[name]); + calls.map((c) => (c.args?.query as Record | undefined)?.[name]); return { calls, call, sentParams }; } @@ -105,11 +105,11 @@ describe('pages — cursor style', () => { expect(calls).toHaveLength(2); }); - it('resumes from a caller-provided cursor, preserving other params', async () => { + it('resumes from a caller-provided cursor, preserving the other query values', async () => { const data = [{ orders: [{ id: 'o3' }] }]; const { call, calls } = stub(data); - await collect(pages(call, CURSOR, { params: { cursor: 'c2', limit: 5 } })); - expect(calls[0].args?.params).toEqual({ cursor: 'c2', limit: 5 }); + await collect(pages(call, CURSOR, { query: { cursor: 'c2', limit: 5 } })); + expect(calls[0].args?.query).toEqual({ cursor: 'c2', limit: 5 }); }); it('advances through numeric cursors end-to-end', async () => { @@ -144,15 +144,15 @@ describe('pages — cursor style', () => { ); }); - it('never mutates the caller args; each request gets a fresh params clone', async () => { + it('never mutates the caller args; each request gets a fresh query bag', async () => { const data = [{ orders: [{ id: 'o1' }], nextCursor: 'c2' }, { orders: [] }]; - const args = { params: { limit: 2 }, headers: { 'X-Trace': '1' } }; + const args = { query: { limit: 2 }, headers: { 'X-Trace': '1' } }; const snapshot = structuredClone(args); const { call, calls } = stub(data); await collect(pages(call, CURSOR, args)); expect(args).toEqual(snapshot); - expect(calls[0].args?.params).not.toBe(args.params); - expect(calls[1].args?.params).not.toBe(calls[0].args?.params); + expect(calls[0].args?.query).not.toBe(args.query); + expect(calls[1].args?.query).not.toBe(calls[0].args?.query); }); it('forwards the same init (incl. AbortSignal) to every call', async () => { @@ -189,7 +189,7 @@ describe('pages — offset style', () => { it('starts at the caller offset when provided', async () => { const data = [{ orders: ['k'] }, { orders: [] }]; const { call, sentParams } = stub(data); - await collect(pages(call, OFFSET, { params: { offset: 10 } })); + await collect(pages(call, OFFSET, { query: { offset: 10 } })); expect(sentParams('offset')).toEqual([10, 11]); }); @@ -198,7 +198,7 @@ describe('pages — offset style', () => { // omits the param for those values, so the iterator must not start at page 0. const data = [{ orders: ['k'] }, { orders: [] }]; const { call, sentParams } = stub(data); - await collect(pages(call, PAGE, { params: { page: null } })); + await collect(pages(call, PAGE, { query: { page: null } })); expect(sentParams('page')).toEqual([1, 2]); }); @@ -206,14 +206,14 @@ describe('pages — offset style', () => { const data = [{ orders: ['k', 'm'] }, { orders: [] }]; const { call, sentParams } = stub(data); // A string offset (common from URL/form input): `'10' + 2` would be `'102'` without coercion. - await collect(pages(call, OFFSET, { params: { offset: '10' } })); + await collect(pages(call, OFFSET, { query: { offset: '10' } })); expect(sentParams('offset')).toEqual([10, 12]); }); it('falls back to the default start when the offset param is not a number', async () => { const data = [{ orders: ['k'] }, { orders: [] }]; const { call, sentParams } = stub(data); - await collect(pages(call, OFFSET, { params: { offset: 'not-a-number' } })); + await collect(pages(call, OFFSET, { query: { offset: 'not-a-number' } })); expect(sentParams('offset')).toEqual([0, 1]); }); @@ -236,7 +236,7 @@ describe('pages — page style', () => { it('starts at the caller page number when provided', async () => { const data = [{ orders: ['x'] }, { orders: [] }]; const { call, sentParams } = stub(data); - await collect(pages(call, PAGE, { params: { page: 5 } })); + await collect(pages(call, PAGE, { query: { page: 5 } })); expect(sentParams('page')).toEqual([5, 6]); }); }); @@ -273,7 +273,7 @@ describe('items', () => { const data = [{ orders: ['a'] }, { orders: [] }]; const init: RequestOptions = { headers: { 'X-Trace': '1' } }; const { call, calls, sentParams } = stub(data); - await collect(items(call, PAGE, { params: { limit: 1 } }, init)); + await collect(items(call, PAGE, { query: { limit: 1 } }, init)); expect(sentParams('limit')).toEqual([1, 1]); for (const c of calls) expect(c.init).toBe(init); }); @@ -311,15 +311,15 @@ describe('pagesByLink / itemsByLink (link style)', () => { return { call, calls }; } - it('follows rel="next" by merging its query params into the next call, then stops', async () => { + it('follows rel="next" by merging its query query into the next call, then stops', async () => { const { call, calls } = linkStub([ { page: ['a'], linkHeader: '; rel="next"' }, { page: ['b'], linkHeader: null }, ]); - const seen = await collect(pagesByLink(call, { params: { per_page: 5 } })); + const seen = await collect(pagesByLink(call, { query: { per_page: 5 } })); expect(seen).toEqual([['a'], ['b']]); - expect(calls[0].args?.params).toEqual({ per_page: 5 }); - expect(calls[1].args?.params).toEqual({ per_page: '5', page: '2' }); + expect(calls[0].args?.query).toEqual({ per_page: 5 }); + expect(calls[1].args?.query).toEqual({ per_page: '5', page: '2' }); }); it('keeps every value of a repeated query param in the next target', async () => { @@ -328,7 +328,7 @@ describe('pagesByLink / itemsByLink (link style)', () => { { page: ['b'], linkHeader: null }, ]); await collect(pagesByLink(call, {})); - expect(calls[1].args?.params).toEqual({ tag: ['dogs', 'cats'], page: '2' }); + expect(calls[1].args?.query).toEqual({ tag: ['dogs', 'cats'], page: '2' }); }); it('resolves a relative next target against the page URL', async () => { @@ -337,7 +337,7 @@ describe('pagesByLink / itemsByLink (link style)', () => { { page: [2], linkHeader: null }, ]); await collect(pagesByLink(call)); - expect(calls[1].args?.params).toEqual({ cursor: 'abc' }); + expect(calls[1].args?.query).toEqual({ cursor: 'abc' }); }); it('follows links when the page URL itself is relative (relative serverUrl, mocked fetch)', async () => { @@ -354,7 +354,7 @@ describe('pagesByLink / itemsByLink (link style)', () => { }; const seen = await collect(pagesByLink(call)); expect(seen).toEqual([['a'], ['b']]); - expect(calls[1].args?.params).toEqual({ page: '2' }); + expect(calls[1].args?.query).toEqual({ page: '2' }); }); it('throws when the next target repeats (infinite-loop guard)', async () => { diff --git a/packages/client-generator/src/runtime/cli.ts b/packages/client-generator/src/runtime/cli.ts new file mode 100644 index 0000000000..575d842a22 --- /dev/null +++ b/packages/client-generator/src/runtime/cli.ts @@ -0,0 +1,807 @@ +// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the +// instance client and maps outcomes to the documented exit-code contract +// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature, +// but every effect (env, stdin, files, output) is injected through the wiring so +// 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; +}; + +/** 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; +}; + +export type CliInvocation = + | { kind: 'help'; topic?: CliCommand | string } + | { kind: 'schema'; command: CliCommand } + | { + kind: 'run'; + command: CliCommand; + positionals: Record; + params: Record; + globals: CliGlobals; + } + | { 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 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; +}; + +type ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] }; + +/** Custom commands as the command shape the parser reads; generated ones pass through. */ +function normalizeCommands(commands: Array): ResolvedCommand[] { + return commands.map((command) => + 'handler' in command + ? { method: '', path: '', positionals: [], flags: [], ...command } + : command + ); +} + +/** + * The name of a custom command that shadows another command. Rejected at startup: an + * operator typing an operationId must never silently run something else. + */ +function shadowedCommandName(commands: ResolvedCommand[]): string | undefined { + const seen = new Map(); + for (const command of commands) { + const key = `${command.group ?? ''}\u0000${command.name}`; + seen.set(key, [...(seen.get(key) ?? []), command]); + } + for (const clashing of seen.values()) { + if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) { + return clashing[0].name; + } + } + return undefined; +} + +const GLOBAL_FLAGS: Record = { + 'server-url': { key: 'serverUrl' }, + format: { key: 'format' }, + 'dry-run': { key: 'dryRun', boolean: true }, + 'page-all': { key: 'pageAll', boolean: true }, + output: { key: 'output' }, + token: { key: 'token' }, + json: { key: 'json' }, +}; + +/** + * The name to print in help: the command the CLI was invoked as. A global install resolves + * `argv[1]` to the bin itself, so its basename is exactly what the user typed. A Windows + * `.cmd` shim, a `node dist/cafe.cli.js`, and a `tsx client.cli.ts` run all pass the script + * path instead — printing that would name a command nobody can type, so a script extension + * and the `.cli` marker come off: `cafe.cli.js` prints `cafe`. + */ +export function invokedName(scriptPath: string | undefined, fallback: string): string { + if (scriptPath === undefined) return fallback; + const base = scriptPath.replace(/^.*[\\/]/, ''); + const withoutExtension = base.replace(/\.(mjs|cjs|js|mts|cts|ts|cmd|bat|ps1|exe)$/i, ''); + const name = withoutExtension.replace(/\.cli$/i, ''); + 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('-'); +} + +/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */ +function oneLine(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +/** + * The parsed argv as one call input, in the style the wired client takes: grouped by layer + * (the default) or merged into one object. + */ +function callInputs( + command: CliCommand, + positionals: Record, + params: Record, + body: unknown, + argsStyle: CliWiring['argsStyle'] +): Record | undefined { + const inputs: Record = {}; + // A command the generator marked `grouped` keeps the namespaced shape even here. + if (argsStyle === 'flat' && command.argsStyle !== 'grouped') { + Object.assign(inputs, positionals, params); + if (body !== undefined) { + if (command.body?.merged === true) Object.assign(inputs, body as Record); + else inputs.body = body; + } + } else { + if (Object.keys(positionals).length > 0) inputs.path = positionals; + if (Object.keys(params).length > 0) inputs.query = params; + if (body !== undefined) inputs.body = body; + } + return Object.keys(inputs).length > 0 ? inputs : undefined; +} + +/** Resolve argv against the command table. Pure — no I/O, no env. */ +export function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation { + if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' }; + + if (argv[0] === 'schema') { + const command = commands.find((candidate) => candidate.name === argv[1]); + return command + ? { kind: 'schema', command } + : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() }; + } + + const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string))); + // An untagged operation is only ever addressed by its bare name, so when that name is also + // a group slug the name wins — reading it as the group would leave the command unreachable. + // A tagged operation in the same position keeps yielding to group help: it is still + // reachable as ` `. + const untagged = commands.some((c) => c.group === undefined && c.name === argv[0]); + let command: CliCommand | undefined; + let rest: string[]; + if (!untagged && slugs.has(argv[0])) { + if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] }; + command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]); + if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` }; + rest = argv.slice(2); + } else { + // An ungrouped command, or a bare operationId — knowing the group shouldn't be + // required when the name alone is unambiguous. + const named = commands.filter((c) => c.name === argv[0]); + command = + named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined); + if (!command) { + const ambiguous = named.length > 1; + return { + kind: 'usage-error', + message: ambiguous + ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named + .map((c) => groupSlug(c.group as string)) + .join(', ')})` + : `Unknown command: ${argv[0]}`, + }; + } + rest = argv.slice(1); + } + if (rest.includes('--help')) return { kind: 'help', topic: command }; + + const positionals: Record = {}; + const params: Record = {}; + const globals: CliGlobals = {}; + let positionalIndex = 0; + for (let index = 0; index < rest.length; index++) { + const token = rest[index]; + if (!token.startsWith('--')) { + const slot = command.positionals[positionalIndex++]; + if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` }; + positionals[slot.name] = token; + continue; + } + const equals = token.indexOf('='); + const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals); + const inlineValue = equals === -1 ? undefined : token.slice(equals + 1); + const takeValue = (): string | undefined => + inlineValue !== undefined ? inlineValue : rest[++index]; + + const global = GLOBAL_FLAGS[flagName]; + if (global) { + if (global.boolean) { + (globals[global.key] as boolean) = true; + continue; + } + const value = takeValue(); + if (value === undefined) { + return { kind: 'usage-error', message: `Flag --${flagName} expects a value` }; + } + if (global.key === 'format' && value !== 'json' && value !== 'ndjson') { + return { kind: 'usage-error', message: `--format must be one of: json, ndjson` }; + } + (globals[global.key] as string) = value; + continue; + } + + const flag = command.flags.find((candidate) => candidate.name === flagName); + if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` }; + if (flag.type === 'boolean') { + params[flag.param] = true; + continue; + } + const value = takeValue(); + if (value === undefined) { + return { kind: 'usage-error', message: `Flag --${flagName} expects a value` }; + } + if (flag.enum && !flag.enum.includes(value)) { + return { + kind: 'usage-error', + message: `--${flagName} must be one of: ${flag.enum.join(', ')}`, + }; + } + if (flag.type === 'number') { + const numeric = Number(value); + if (Number.isNaN(numeric)) { + return { kind: 'usage-error', message: `--${flagName} expects a number, got "${value}"` }; + } + params[flag.param] = numeric; + } else if (flag.type === 'array') { + const existing = params[flag.param]; + params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value]; + } else { + params[flag.param] = value; + } + } + + for (const slot of command.positionals) { + if (!(slot.name in positionals)) { + return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` }; + } + } + for (const flag of command.flags) { + if (flag.required && !(flag.param in params)) { + return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` }; + } + } + if (globals.json !== undefined && !command.body) { + return { kind: 'usage-error', message: `${command.name} does not accept a request body` }; + } + if (command.body?.required && globals.json === undefined) { + return { + kind: 'usage-error', + message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`, + }; + } + if (globals.pageAll && !command.paginated) { + return { + kind: 'usage-error', + message: `${command.name} is not paginated; --page-all only applies to paginated operations`, + }; + } + 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(); +} + +function resolveAuth(wiring: CliWiring, token: string | undefined): Record { + const env = wiring.env ?? {}; + const prefix = wiring.envPrefix; + const auth: Record = {}; + for (const scheme of wiring.schemes ?? []) { + if (scheme.kind === 'bearer') { + const value = token ?? env[`${prefix}_TOKEN`]; + if (value !== undefined) auth.bearer = value; + } else if (scheme.kind === 'basic') { + const username = env[`${prefix}_USERNAME`]; + const password = env[`${prefix}_PASSWORD`]; + if (username !== undefined && password !== undefined) auth.basic = { username, password }; + } else { + const value = env[`${prefix}_API_KEY_${constantCase(scheme.key)}`]; + if (value !== undefined) { + auth.apiKey = { + ...(auth.apiKey as Record | undefined), + [scheme.key]: value, + }; + } + } + } + return auth; +} + +/** + * One command's complete contract as plain data — what `schema ` prints. It has + * to carry the parameters: 'GET' operations have no body, so without them the output says + * nothing a caller could act on, and the only alternative is scraping `--help`, which is + * prose written for humans. + */ +function commandContract(command: CliCommand): Record { + return { + operationId: command.name, + ...(command.group === undefined ? {} : { group: groupSlug(command.group) }), + ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }), + ...(command.method === '' ? {} : { method: command.method }), + ...(command.path === '' ? {} : { path: command.path }), + parameters: { + path: command.positionals.map((positional) => ({ + name: positional.name, + type: positional.type ?? 'string', + required: true, + ...(positional.description === undefined + ? {} + : { description: oneLine(positional.description) }), + })), + // `name` is what you type (`--max-total`); `param` is the wire name it becomes. + query: command.flags.map((flag) => ({ + name: flag.name, + param: flag.param, + type: flag.type, + required: flag.required, + ...(flag.enum === undefined ? {} : { enum: flag.enum }), + ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }), + })), + }, + ...(command.body === undefined ? {} : { body: command.body }), + ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }), + ...(command.paginated === true ? { paginated: true } : {}), + ...(command.sse === true ? { sse: true } : {}), + ...(command.blob === true ? { blob: true } : {}), + ...(command.schemas ?? {}), + }; +} + +function renderHelp( + commands: CliCommand[], + name: string, + schemes: CliAuthScheme[], + prefix: string, + topic?: CliCommand | string +): string[] { + if (topic !== undefined && typeof topic !== 'string') { + const command = topic; + const usage = [ + name, + ...(command.group ? [groupSlug(command.group)] : []), + command.name, + ...command.positionals.map((slot) => `<${slot.name}>`), + ...(command.flags.length > 0 ? ['[flags]'] : []), + ...(command.body ? ["--json '' | @file | @-"] : []), + ].join(' '); + const lines = [`Usage: ${usage}`]; + if (command.summary) lines.push('', command.summary); + if (command.unsupportedBody !== undefined) { + lines.push( + '', + `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.` + ); + } + if (command.flags.length > 0) { + lines.push('', 'Flags:'); + for (const flag of command.flags) { + const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : ''; + const required = flag.required ? ' [required]' : ''; + lines.push( + ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd() + ); + } + } + return lines; + } + const scope = + typeof topic === 'string' + ? commands.filter((c) => c.group && groupSlug(c.group) === topic) + : commands; + const lines = + typeof topic === 'string' + ? [`Usage: ${name} ${topic} …`, '', 'Commands:'] + : [`Usage: ${name} [group] …`, '', 'Commands:']; + const seenGroups = new Set(); + const grouped = commands.some((c) => c.group); + for (const command of scope) { + if (typeof topic !== 'string' && command.group) { + const slug = groupSlug(command.group); + if (seenGroups.has(slug)) continue; + seenGroups.add(slug); + // The slug is what you type; the tag is what you recognize. + const title = slug === command.group ? '' : ` (${command.group})`; + lines.push(` ${slug} ${title}`); + continue; + } + lines.push( + ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name] + .filter(Boolean) + .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd() + ); + } + // Flags that apply to every command, and the env vars credentials come from: a flag + // absent from --help may as well not exist — and one this API cannot use should not be + // listed at all, since the operator would spend the debugging session on their token. + const kinds = new Set(schemes.map((scheme) => scheme.kind)); + const credentials = [ + ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []), + ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []), + ...schemes + .filter((scheme) => scheme.kind === 'apiKey') + .map((scheme) => `${prefix}_API_KEY_${constantCase(scheme.key)}`), + ]; + lines.push( + '', + 'Global flags:', + ' --server-url Override the baked server URL', + ' --format Output format', + ' --dry-run Print the prepared request without sending it', + ' --page-all Follow pagination, one JSON page per line', + ' --output Write the response body to a file (required for binary)', + ...(kinds.has('bearer') ? [' --token Bearer token'] : []), + ` --json Request body`, + ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []), + '', + `Run ${name} ${grouped ? ' ' : ''} --help for command details; ${name} schema prints its schemas.` + ); + return lines; +} + +function loadBody(source: string, wiring: CliWiring): unknown { + const raw = + source === '@-' + ? (wiring.stdin ?? (() => ''))() + : source.startsWith('@') + ? (wiring.readFile ?? (() => ''))(source.slice(1)) + : source; + return JSON.parse(raw); +} + +/** Replace header values containing a known credential with `***`. */ +function redactHeaders(headers: Record, secrets: string[]): Record { + const redacted: Record = {}; + for (const [name, value] of Object.entries(headers)) { + redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret)) + ? '***' + : value; + } + return redacted; +} + +/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */ +export async function runCli( + commands: Array, + wiring: CliWiring, + argv: string[] +): Promise; +/** The composed form: one binary over several sources, each namespaced with its own wiring. */ +export async function runCli(sources: CommandSource[], argv: string[]): Promise; +export async function runCli( + commandsOrSources: Array | CommandSource[], + wiringOrArgv: CliWiring | string[], + argv?: string[] +): Promise { + if (Array.isArray(wiringOrArgv)) { + return runSources(commandsOrSources as CommandSource[], wiringOrArgv); + } + return runSingle( + commandsOrSources as Array, + wiringOrArgv, + argv ?? [] + ); +} + +/** Route the first token to its source; the namespace-less source owns the root. */ +async function runSources(sources: CommandSource[], argv: string[]): Promise { + // A source without wiring inherits the first wired one, so the documented root-source + // shape `{ commands: [login] }` works: the login shares the composed binary's identity. + const inherited = sources.find((source) => source.wiring !== undefined)?.wiring; + const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring); + // Top-level output goes through the first source: with a root source that is the one + // carrying the shared commands, otherwise the first API listed. + const top = wiringOf(sources[0]); + const fail = (code: number, message: string): number => { + top.stderr(JSON.stringify({ error: { code, message } })); + return code; + }; + const namespaced = sources.filter( + (source): source is CommandSource & { namespace: string } => source.namespace !== undefined + ); + const root = sources.find((source) => source.namespace === undefined); + if (root !== undefined) { + const clash = root.commands.find((command) => + namespaced.some((source) => source.namespace === command.name) + ); + if (clash !== undefined) { + return fail( + 4, + `Root command "${clash.name}" collides with the "${clash.name}" namespace — rename one of them.` + ); + } + } + if (argv.length === 0 || argv[0] === '--help') { + for (const line of renderComposedHelp(sources, top.name)) top.stdout(line); + return 0; + } + const source = namespaced.find((candidate) => candidate.namespace === argv[0]); + if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1)); + const rootTakes = + root !== undefined && + (argv[0] === 'schema' || + root.commands.some( + (command) => + command.name === argv[0] || + (command.group !== undefined && groupSlug(command.group) === argv[0]) + )); + if (rootTakes) + return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv); + return fail( + 4, + `Unknown command: ${argv[0]} — expected an API namespace (${namespaced + .map((candidate) => candidate.namespace) + .join(', ')})${root !== undefined ? ' or a root command' : ''}` + ); +} + +/** The composed top-level help: namespaces, root commands, and how to descend. */ +function renderComposedHelp(sources: CommandSource[], name: string): string[] { + const lines = [`Usage: ${name} …`, '', 'APIs:']; + for (const source of sources) { + if (source.namespace !== undefined) lines.push(` ${source.namespace}`); + } + const root = sources.find((source) => source.namespace === undefined); + if (root !== undefined && root.commands.length > 0) { + lines.push('', 'Commands:'); + for (const command of root.commands) { + lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd()); + } + } + lines.push('', `Run ${name} --help for that API's commands.`); + return lines; +} + +async function runSingle( + rawCommands: Array, + wiring: CliWiring, + argv: string[] +): Promise { + const { stdout, stderr } = wiring; + const commands = normalizeCommands(rawCommands); + const shadowed = shadowedCommandName(commands); + if (shadowed !== undefined) { + stderr( + JSON.stringify({ + error: { + code: 4, + message: `Custom command "${shadowed}" collides with another command of the same name — rename it.`, + }, + }) + ); + return 4; + } + const fail = (code: number, error: Record): number => { + stderr(JSON.stringify({ error: { code, ...error } })); + return code; + }; + + const invocation = parseInvocation(commands, argv); + if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message }); + if (invocation.kind === 'help') { + for (const line of renderHelp( + commands, + wiring.name, + wiring.schemes ?? [], + wiring.envPrefix, + invocation.topic + )) + stdout(line); + return 0; + } + if (invocation.kind === 'schema') { + stdout(JSON.stringify(commandContract(invocation.command), null, 2)); + return 0; + } + + const { command, positionals, params, globals } = invocation; + // A credential the user passed explicitly must never be dropped in silence: without a + // bearer scheme the request would go out unauthenticated and come back 401, which reads + // as "my token is wrong" rather than "that flag does nothing here". + const schemes = wiring.schemes ?? []; + if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) { + const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', '); + return fail(4, { + message: + `--token is a bearer credential, and this API declares no bearer scheme. ` + + (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`), + }); + } + if (command.blob && globals.output === undefined) { + return fail(4, { + message: `${command.name} downloads a file: pass --output `, + operationId: command.name, + }); + } + let body: unknown; + if (globals.json !== undefined) { + try { + body = loadBody(globals.json, wiring); + } catch (error) { + return fail(4, { + message: `Invalid --json body: ${(error as Error).message}`, + operationId: command.name, + }); + } + } + + const auth = resolveAuth(wiring, globals.token); + if (Object.keys(auth).length > 0) wiring.configure({ auth }); + if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl }); + + // Redaction matches these against header VALUES — so basic auth must contribute the + // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw + // password, which never appears in the encoded header. + const basic = auth.basic as { username: string; password: string } | undefined; + const secrets = [ + ...(typeof auth.bearer === 'string' ? [auth.bearer] : []), + ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []), + ...Object.values((auth.apiKey as Record | undefined) ?? {}), + ]; + let captured: Record | undefined; + if (globals.dryRun) { + wiring.configure({ + fetch: async ( + url: string, + init: { method?: string; headers?: Record; body?: unknown } + ) => { + captured = { + url, + method: init.method, + headers: redactHeaders(init.headers ?? {}, secrets), + ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}), + }; + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); + }, + }); + } + + const argument = callInputs(command, positionals, params, body, wiring.argsStyle); + + // The client's methods are typed per-operation; the dispatcher only needs "callable + // by name", so one localized widening here keeps the emitted wiring cast-free. + const methods = wiring.client as Record; + try { + const resolved = command as ResolvedCommand; + if (resolved.handler !== undefined) { + return await resolved.handler({ positionals, params, globals, wiring }); + } + if (globals.pageAll && !globals.dryRun) { + const paginated = methods[command.name] as { + pages: (variables?: unknown) => AsyncIterable; + }; + for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page)); + return 0; + } + const method = methods[command.name] as (variables?: unknown) => Promise; + const result = await method(argument); + if (globals.dryRun) { + // An SSE method returns a lazy stream — drain the stubbed response so its fetch + // actually runs and captures the request. + if (command.sse) for await (const _event of result as AsyncIterable); + stdout(JSON.stringify(captured, null, 2)); + return 0; + } + if (command.sse) { + for await (const event of result as AsyncIterable) stdout(JSON.stringify(event)); + return 0; + } + if (command.blob) { + const bytes = new Uint8Array(await (result as Blob).arrayBuffer()); + (wiring.writeFile ?? (() => {}))(globals.output as string, bytes); + stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length })); + return 0; + } + if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2)); + return 0; + } catch (error) { + const thrown = error as Error & { status?: number; issues?: unknown }; + const detail = { + message: thrown.message, + operationId: command.name, + ...(thrown.status !== undefined ? { status: thrown.status } : {}), + ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}), + }; + if (thrown.name === 'ZodValidationError') return fail(3, detail); + if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) { + return fail(2, detail); + } + return fail(1, detail); + } +} diff --git a/packages/client-generator/src/runtime/create-client.ts b/packages/client-generator/src/runtime/create-client.ts index d63205a45f..95d2765ddf 100644 --- a/packages/client-generator/src/runtime/create-client.ts +++ b/packages/client-generator/src/runtime/create-client.ts @@ -74,14 +74,66 @@ export type Capabilities = SendCapabilities & { }; }; -/** The grouped args wire shape: path params by name plus the `params`/`body`/`headers`/`cookies` slots. */ +/** + * One call's inputs, namespaced by transport layer. `argsStyle: 'flat'` clients accept the + * merged form instead (every parameter and body property at one level) — `namespaceArgs` + * converts it to this shape before anything downstream reads it. + */ export type OperationArgs = { - params?: Record; + path?: Record; + query?: Record; body?: unknown; headers?: Record; cookies?: Record; } & Record; +/** The five layer keys, and the only top-level keys a namespaced call may carry. */ +const LAYERS: readonly string[] = ['path', 'query', 'body', 'headers', 'cookies']; + +/** Where a declared parameter's `in` value puts it. */ +const LAYER_OF: Record = { + path: 'path', + query: 'query', + header: 'headers', + cookie: 'cookies', +}; + +/** + * Merged (`argsStyle: 'flat'`) args → the namespaced shape. A key that names a declared + * parameter goes to that parameter's layer; anything else is a property of the request + * body, which is how a flat call spells an object body. `body` stays reserved for the + * operations a flat call cannot merge (an array, a scalar, or a binary body). + */ +function namespaceArgs(op: OperationDescriptor, args: OperationArgs): OperationArgs { + const layers: Record> = {}; + let body: unknown; + let properties: Record | undefined; + const layerOfParam = new Map((op.params ?? []).map((param) => [param.name, param.in])); + for (const [key, value] of Object.entries(args)) { + const layer = LAYER_OF[layerOfParam.get(key) ?? '']; + if (layer !== undefined) { + (layers[layer] ??= {})[key] = value; + } else if (key === 'body' && op.body !== undefined) { + body = value; + } else if (op.body !== undefined) { + (properties ??= {})[key] = value; + } else { + throw new TypeError( + `Unknown argument "${key}" for operation "${op.id}": it names no declared parameter, and the operation takes no request body.` + ); + } + } + const namespaced: OperationArgs = {}; + if (layers.path) namespaced.path = layers.path; + // The flat surface types every query value, so the collected bag is one by construction. + if (layers.query) namespaced.query = layers.query as Record; + if (layers.headers) namespaced.headers = layers.headers; + if (layers.cookies) namespaced.cookies = layers.cookies; + if (properties !== undefined) namespaced.body = properties; + else if (body !== undefined) namespaced.body = body; + return namespaced; +} + /** The response reader implied by the descriptor (before any per-call `parseAs` override). */ /** * The `Accept` header matching how the response will be read — a blob/text operation @@ -104,31 +156,35 @@ function kindFor(op: OperationDescriptor): ParseAs | 'void' { return 'auto'; } -/** Route the grouped args by the descriptor: path values, query object, body, extra headers, cookies. */ +/** + * The call's inputs in namespaced form, converting first on a flat-style client. An + * operation the generator marked `argsStyle: 'grouped'` is already namespaced — its names + * could not be merged, so its input type never offered the flat shape. + */ +function inputOf( + op: OperationDescriptor, + args: OperationArgs, + config: ClientConfig +): OperationArgs { + const merged = config.argsStyle === 'flat' && op.argsStyle !== 'grouped'; + return merged ? namespaceArgs(op, args) : args; +} + +/** Route the namespaced args to the request pieces. */ function splitArgs(op: OperationDescriptor, args: OperationArgs) { - const path: Record = {}; - const pathNames = new Set(); - for (const param of op.params ?? []) { - if (param.in === 'path') { - pathNames.add(param.name); - path[param.name] = args[param.name]; - } - } - // An unknown top-level key can only be a bug (usually a flat-style call shape passed - // to a grouped client: `{ limit: 10 }` instead of `{ params: { limit: 10 } }`). - // TypeScript catches it, but transpilers that skip type-checking would otherwise - // ship a request that silently drops the value — fail the call loudly instead. + // An unknown layer key can only be a bug (usually flat-style args on a namespaced + // client). TypeScript catches it, but a transpiler that skips type-checking would + // otherwise ship a request that silently drops the value — fail the call loudly. for (const key of Object.keys(args)) { - if (key === 'params' || key === 'body' || key === 'headers' || key === 'cookies') continue; - if (pathNames.has(key)) continue; - throw new TypeError( - `Unknown argument "${key}" for operation "${op.id}". Query parameters go under params: { … } and the request body under body; valid keys are params, body, headers, cookies` + - (pathNames.size > 0 ? `, and the path parameters (${[...pathNames].join(', ')}).` : '.') - ); + if (!LAYERS.includes(key)) { + throw new TypeError( + `Unknown argument "${key}" for operation "${op.id}". Inputs are grouped by layer: ${LAYERS.join(', ')}.` + ); + } } return { - path, - query: args.params, + path: args.path ?? {}, + query: args.query, body: args.body, headers: args.headers, cookies: args.cookies, @@ -405,7 +461,8 @@ export function createClientCore< for (const [name, op] of Object.entries(operations)) { if (op.responseKind === 'sse') { - const method = (args: OperationArgs = {}, init: SseOptions = {}) => { + const method = (given: OperationArgs = {}, init: SseOptions = {}) => { + const args = inputOf(op, given, config); if (!caps.sse) { throw new Error(`SSE capability not wired: cannot stream operation "${op.id}"`); } @@ -429,8 +486,13 @@ export function createClientCore< Object.defineProperty(method, 'operationId', { value: op.id }); client[name] = method; } else { - const method = (args: OperationArgs = {}, init: RequestOptions = {}) => + // `raw` takes namespaced args; `method` is the public entry that accepts whichever + // style the client was generated with. The iterators namespace once and then drive + // `raw`, so a flat call is never converted twice. + const raw = (args: OperationArgs = {}, init: RequestOptions = {}) => execute(config, op, args, init, caps); + const method = (args: OperationArgs = {}, init: RequestOptions = {}) => + raw(inputOf(op, args, config), init); Object.defineProperty(method, 'name', { value: name }); Object.defineProperty(method, 'operationId', { value: op.id }); const spec = op.pagination; @@ -449,31 +511,42 @@ export function createClientCore< pages: (args?: OperationArgs, init?: RequestOptions) => paginateCapability(caps, op).pagesByLink( linkPageCall(config, op, caps), - args, + inputOf(op, args ?? {}, config), init ), items: (args?: OperationArgs, init?: RequestOptions) => paginateCapability(caps, op).itemsByLink( linkPageCall(config, op, caps), spec, - args, + inputOf(op, args ?? {}, config), init ), }) : Object.assign(method, { pages: (args?: OperationArgs, init?: RequestOptions) => - paginateCapability(caps, op).pages(pageCall(method, config), spec, args, init), + paginateCapability(caps, op).pages( + pageCall(raw, config), + spec, + inputOf(op, args ?? {}, config), + init + ), items: (args?: OperationArgs, init?: RequestOptions) => - paginateCapability(caps, op).items(pageCall(method, config), spec, args, init), + paginateCapability(caps, op).items( + pageCall(raw, config), + spec, + inputOf(op, args ?? {}, config), + init + ), }); } } // Core members are assigned AFTER the operation loop — they win over colliding op names. client.configure = (next: ClientConfig): void => { - // `errorMode` is fixed at generate time (it shapes the static types); flipping it at - // runtime would silently desync return shapes from `Client`, so it is ignored. - const { errorMode: _fixed, auth, ...rest } = next; + // `errorMode` and `argsStyle` are fixed at generate time (they shape the static types); + // flipping either at runtime would silently desync the calls from `Client`, so both + // are ignored here. + const { errorMode: _fixedMode, argsStyle: _fixedStyle, auth, ...rest } = next; Object.assign(config, rest); // `auth` merges into existing credentials (like the `auth.*` setters) rather than // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set diff --git a/packages/client-generator/src/runtime/paginate.ts b/packages/client-generator/src/runtime/paginate.ts index 186a446a96..f794ad2986 100644 --- a/packages/client-generator/src/runtime/paginate.ts +++ b/packages/client-generator/src/runtime/paginate.ts @@ -5,7 +5,7 @@ import type { PaginationSpec, QueryValue, RequestOptions } from './types.js'; * Auto-pagination (capability module — wired into `createClient`, dispatched by the * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's * `param` query parameter, per its `style`. The caller's args are never mutated — each - * request gets a fresh `params` clone — and `init` is forwarded to every call. + * request gets a fresh `query` clone — and `init` is forwarded to every call. * * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a * result-mode client the attachment unwraps the envelope first), so a failed page @@ -39,7 +39,7 @@ export function resolvePointer(value: unknown, pointer: string): unknown { /** * Iterate an operation's full page results. Every page is yielded before the stop * condition is evaluated, so the last page always arrives. Cursor style resumes from a - * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer + * caller-provided `query[spec.param]`, stops when the optional `hasMore` pointer * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and * throws if the next cursor is not a string or number, or * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page @@ -53,11 +53,11 @@ export async function* pages( init?: RequestOptions ): AsyncGenerator { if (spec.style === 'cursor') { - let cursor: unknown = args.params?.[spec.param]; + let cursor: unknown = args.query?.[spec.param]; while (true) { - const params = { ...args.params }; - if (cursor !== undefined) params[spec.param] = cursor as QueryValue; - const page = await call({ ...args, params }, init); + const query = { ...args.query }; + if (cursor !== undefined) query[spec.param] = cursor as QueryValue; + const page = await call({ ...args, query }, init); yield page; // Connection-style APIs keep a non-null cursor on the last page and signal the // end via a boolean flag — honor it before the cursor check to skip the @@ -80,20 +80,17 @@ export async function* pages( // cannot carry — the client wires those operations to `pagesByLink` instead. throw new Error('link-style pagination iterates via pagesByLink'); } else { - // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a + // Coerce the starting position to a number: a caller may pass `query[spec.param]` as a // string (common from URL/form input), and `+=` on a string would concatenate. `null` // and `''` count as absent — `Number` would turn them into 0, but a one-shot call // omits the param for those values, so the iterator must not start at position 0. - const start = args.params?.[spec.param]; + const start = args.query?.[spec.param]; const fallback = spec.style === 'page' ? 1 : 0; const absent = start === undefined || start === null || start === ''; let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start); let previousItems: string | undefined; while (true) { - const page = await call( - { ...args, params: { ...args.params, [spec.param]: position } }, - init - ); + const page = await call({ ...args, query: { ...args.query, [spec.param]: position } }, init); const pageItems = resolvePointer(page, spec.items); // Some APIs clamp a past-the-end offset/page to the last non-empty page instead // of returning an empty one — the repeated page would otherwise loop forever @@ -164,10 +161,10 @@ export async function* pagesByLink( args: OperationArgs = {}, init?: RequestOptions ): AsyncGenerator { - let params = args.params; + let query = args.query; let previous: string | undefined; while (true) { - const { page, linkHeader, url } = await call({ ...args, params }, init); + const { page, linkHeader, url } = await call({ ...args, query }, init); yield page as TPage; const target = linkNext(linkHeader); if (target === undefined) return; @@ -190,7 +187,7 @@ export async function* pagesByLink( else if (Array.isArray(seen)) seen.push(value); else linkParams[key] = [seen, value]; } - params = { ...args.params, ...linkParams }; + query = { ...args.query, ...linkParams }; } } diff --git a/packages/client-generator/src/runtime/types.ts b/packages/client-generator/src/runtime/types.ts index 03fd5febee..5421fe17f9 100644 --- a/packages/client-generator/src/runtime/types.ts +++ b/packages/client-generator/src/runtime/types.ts @@ -71,6 +71,12 @@ export type OperationDescriptor = { /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */ security?: readonly (readonly SecuritySpec[])[]; pagination?: PaginationSpec; + /** + * `'grouped'` marks an operation that takes its inputs namespaced by layer even on a + * `argsStyle: 'flat'` client — the generator sets it where a merged call could not carry + * one name for two layers, and the operation's own input type says the same. + */ + argsStyle?: 'grouped'; /** * Declared success-response headers for throw-mode `{ envelope: true }`. * `name` is the lowercased wire name; `key` is the camelCase envelope property. @@ -199,6 +205,12 @@ export type ClientConfig = { auth?: AuthCredentials; /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */ errorMode?: 'throw' | 'result'; + /** + * How each call spells its inputs: `'grouped'` (the default) namespaces them by layer — + * `{ path, query, headers, cookies, body }` — and `'flat'` takes one merged object. + * Fixed at generate time, like `errorMode`, because it shapes the static types. + */ + argsStyle?: 'grouped' | 'flat'; onRequest?: Middleware['onRequest']; onResponse?: Middleware['onResponse']; onError?: Middleware['onError']; diff --git a/packages/client-generator/src/types.ts b/packages/client-generator/src/types.ts index 5642e332ce..59ef57c853 100644 --- a/packages/client-generator/src/types.ts +++ b/packages/client-generator/src/types.ts @@ -59,8 +59,8 @@ export type GenerateClientOptions = { * generated APIs share one QueryClient (operationIds may collide across APIs). */ queryKeyPrefix?: string; /** - * Generators to run, in order. Defaults to `['sdk']`. Each entry is a built-in name - * (`sdk`/`zod`/`tanstack-query`/`swr`/`transformers`/`mock`), the `name` of an inline + * Generators to run, in order. Defaults to `['typescript']`. Each entry is a built-in name + * (`typescript`/`zod`/`tanstack-query`/`swr`/`transformers`/`mock`), the `name` of an inline * `customGenerators` entry, or an import specifier (a path or package) for a custom generator. */ generators?: string[]; @@ -88,10 +88,40 @@ export type GenerateClientOptions = { * `'ts'` suits 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`. */ + goPackage?: string; + /** + * Path of a COMPOSED cli entry spanning every api that selects the `cli` generator — + * one binary, each api behind its alias as a namespace. Read by the `redocly` CLI + * across apis (top-level `client` block only); `generateClient(...)` itself ignores it. + */ + cliOutput?: string; + /** + * Per-generator options, keyed by generator name — validated against the schema the + * generator declares (`GeneratorOptionsSchema`) before it runs. Config-only, like + * `pagination`: a generator's option set is its own vocabulary, not a CLI flag. + */ + options?: Record>; + /** + * Emit `.code-samples.yaml` — an OpenAPI Overlay adding per-operation + * `x-codeSamples` collected from every selected generator that implements `sample()`. + * Config-only (`client.codeSamples`), like `pagination`. + */ + codeSamples?: boolean; + /** + * Also write reference documentation for what this run generates: one Markdown page per + * selected generator that implements the `docs` hook (`.cli.md` for the CLI, + * `.python.md` for the Python SDK, and so on). One switch for every language, so a + * newly documented generator needs no new flag. The `--docs` flag sets it too. + */ + docs?: boolean; + /** Emit YAML front matter carrying the title above each documentation page, for docs + * sites that expect it. Config-only. */ + docsFrontmatter?: boolean; /** * Auto-pagination rules: a convention rule (applied to every operation it * structurally fits), per-operation overrides, and `exclude`d operationIds — - * resolved together with each operation's `x-redocly-pagination` extension (per-op config > + * resolved together with each operation's `x-redoclyPagination` extension (per-op config > * extension > convention). Paginated operations gain typed `.pages()`/`.items()` * iterators. Verified statically: an explicit rule that doesn't fit its operation * fails generation. diff --git a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap index 61d9e0c6fe..7617d7c859 100644 --- a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap @@ -219,12 +219,24 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "grouped", ], }, + "cliOutput": { + "type": "string", + }, + "codeSamples": { + "type": "boolean", + }, "dateType": { "enum": [ "string", "Date", ], }, + "docs": { + "type": "boolean", + }, + "docsFrontmatter": { + "type": "boolean", + }, "errorMode": { "enum": [ "throw", @@ -237,6 +249,9 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] }, "type": "array", }, + "goPackage": { + "type": "string", + }, "importExt": { "enum": [ "js", @@ -252,6 +267,11 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "mockSeed": { "type": "number", }, + "options": { + "additionalProperties": [Function], + "name": "ClientGeneratorOptionsMap", + "properties": {}, + }, "outputMode": { "enum": [ "single", @@ -276,6 +296,10 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] }, }, }, + "ClientGeneratorOptions": { + "additionalProperties": {}, + "properties": {}, + }, "ClientPagination": { "properties": { "cursorParam": { diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index 5b6561dacc..05a6679447 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -379,15 +379,28 @@ const Client: NodeType = { outputMode: { enum: ['single', 'split'] }, runtime: { enum: ['inline', 'package'] }, importExt: { enum: ['js', 'ts'] }, + goPackage: { type: 'string' }, + cliOutput: { type: 'string' }, errorMode: { enum: ['throw', 'result'] }, dateType: { enum: ['string', 'Date'] }, mockData: { enum: ['static', 'faker'] }, mockSeed: { type: 'number' }, queryKeyPrefix: { type: 'string' }, + codeSamples: { type: 'boolean' }, + docs: { type: 'boolean' }, + docsFrontmatter: { type: 'boolean' }, setup: { type: 'string' }, + options: mapOf('ClientGeneratorOptions'), pagination: 'ClientPagination', }, }; + +// Options a generator declares itself, so the vocabulary is the generator's, not ours; +// `generate-client` validates each block against the schema its generator declares. +const ClientGeneratorOptions: NodeType = { + properties: {}, + additionalProperties: {}, +}; const ClientPaginationRule: NodeType = { properties: { style: { enum: ['cursor', 'offset', 'page', 'link'] }, @@ -805,6 +818,7 @@ const CoreConfigTypes: Record = { ConfigGovernance, ConfigHTTP, Client, + ClientGeneratorOptions, ClientPagination, ClientPaginationRule, Where, diff --git a/tests/e2e/generate-client/.gitignore b/tests/e2e/generate-client/.gitignore new file mode 100644 index 0000000000..ceddaa37f1 --- /dev/null +++ b/tests/e2e/generate-client/.gitignore @@ -0,0 +1 @@ +.cache/ diff --git a/tests/e2e/generate-client/args-grouped.test.ts b/tests/e2e/generate-client/args-grouped.test.ts index 0ec1662d3e..ab0c845536 100644 --- a/tests/e2e/generate-client/args-grouped.test.ts +++ b/tests/e2e/generate-client/args-grouped.test.ts @@ -46,11 +46,11 @@ describe('generate-client end-to-end (--args-style grouped)', () => { expect(src).toMatch(/export const \{ [^}]*getOrderById[^}]* \} = client;/); // The grouped `Variables` aliases are still emitted for consumers. expect(src).toContain('export type GetOrderByIdVariables = {'); - // getOrderById's grouped args carry the path param as a member in Ops. - expect(src).toMatch(/getOrderById: \{\s*args: \{[\s\S]*?orderId: string;/); + // getOrderById's args carry the path parameter inside its own layer. + expect(src).toMatch(/getOrderById: \{\s*args: \{\s*path: GetOrderByIdPath;/); - // No flat positional sugar leaks through in grouped mode. - expect(src).not.toContain('export const getOrderById = (orderId: string'); + // Nothing wraps the method: the export IS the method. + expect(src).not.toContain('=> client.getOrderById('); }, 90_000); test('the grouped-style client type-checks under strict mode with no unused locals', () => { diff --git a/tests/e2e/generate-client/auth.test.ts b/tests/e2e/generate-client/auth.test.ts index 22acd8a1b2..0fb0fbc3a6 100644 --- a/tests/e2e/generate-client/auth.test.ts +++ b/tests/e2e/generate-client/auth.test.ts @@ -30,18 +30,15 @@ describe('generate-client auth breadth (auth.yaml)', () => { expect(generated).toContain('async function resolveAuth('); expect(generated).toContain('async function resolveToken('); - // One setter per scheme kind, as instance-bound sugar. Three apiKey schemes (none sole) → keyed names. - expect(generated).toContain('export const setBearer = client.auth.bearer;'); - expect(generated).toContain('export const setBasicAuth = client.auth.basic;'); - expect(generated).toContain( - 'export const setApiKeyQueryKey = (value: TokenProvider) => client.auth.apiKey("QueryKey", value);' - ); - expect(generated).toContain( - 'export const setApiKeyHeaderKey = (value: TokenProvider) => client.auth.apiKey("HeaderKey", value);' - ); - expect(generated).toContain( - 'export const setApiKeyCookieKey = (value: TokenProvider) => client.auth.apiKey("CookieKey", value);' - ); + // Credentials go through `configure({ auth })` or `client.auth.*`; the module exports + // no per-scheme setter, so a scheme's key never becomes a reserved export name. + expect(generated).toContain('export const { configure, use } = client;'); + expect(generated).not.toContain('export const setBearer'); + expect(generated).not.toContain('export const setBasicAuth'); + expect(generated).not.toContain('export const setApiKey'); + // Each scheme still reaches the runtime through the descriptor that requires it. + expect(generated).toContain('scheme: "QueryKey"'); + expect(generated).toContain('scheme: "CookieKey"'); // Per-kind injection inside resolveAuth, driven by the descriptors' security specs. expect(generated).toContain('headers.Authorization = `Bearer ${await resolveToken(provider)}`'); @@ -83,10 +80,10 @@ describe('generate-client auth breadth (auth.yaml)', () => { // Behavioral check on a real wire. The cafe mock-server harness is bound to // cafe.yaml and heavy to clone, so we drive the generated client against a tiny - // throwaway http server instead — enough to prove (a) an async `setBearer` + // throwaway http server instead — enough to prove (a) an async bearer provider // token function resolves through the runtime's auth capability onto the // `Authorization` header and (b) a query-key scheme lands `api_key=` in the URL. - it('async setBearer resolves onto Authorization and query-key lands in the URL', () => { + it('an async bearer provider resolves onto Authorization and a query key lands in the URL', () => { // The driver owns its own throwaway http server (and points the client at it // via configure({ serverUrl })), so a single `runConsumer` runs the whole behavioral // probe — the server can't be starved by the test process's blocking spawn. @@ -98,7 +95,7 @@ describe('generate-client auth breadth (auth.yaml)', () => { dir, outdent` import * as http from 'node:http'; - import { configure, getBearer, getQuery, setBearer, setApiKeyQueryKey } from './client.js'; + import { client, configure, getBearer, getQuery } from './client.js'; const captured: Array<{ url: string; auth?: string }> = []; const server = http.createServer((req, res) => { @@ -111,10 +108,10 @@ describe('generate-client auth breadth (auth.yaml)', () => { await new Promise((r) => server.listen(0, '127.0.0.1', r)); const port = (server.address() as { port: number }).port; configure({ serverUrl: 'http://127.0.0.1:' + port }); - setBearer(async () => 'tok'); + client.auth.bearer(async () => 'tok'); await getBearer(); - setApiKeyQueryKey('secret-key'); - await getQuery({ limit: 5 }); + client.auth.apiKey('QueryKey', 'secret-key'); + await getQuery({ query: { limit: 5 } }); await new Promise((r) => server.close(() => r())); process.stdout.write(JSON.stringify(captured)); } diff --git a/tests/e2e/generate-client/base-consumer/index-cancel.ts b/tests/e2e/generate-client/base-consumer/index-cancel.ts index f011811716..7a949c8ec1 100644 --- a/tests/e2e/generate-client/base-consumer/index-cancel.ts +++ b/tests/e2e/generate-client/base-consumer/index-cancel.ts @@ -2,7 +2,7 @@ import { getSlowPet } from './api.js'; async function main(): Promise { const controller = new AbortController(); - const promise = getSlowPet(1, { signal: controller.signal }); + const promise = getSlowPet({ path: { id: 1 } }, { signal: controller.signal }); setTimeout(() => controller.abort(), 100); try { await promise; diff --git a/tests/e2e/generate-client/base-consumer/index.ts b/tests/e2e/generate-client/base-consumer/index.ts index 08f9b3b2df..3c1187d2db 100644 --- a/tests/e2e/generate-client/base-consumer/index.ts +++ b/tests/e2e/generate-client/base-consumer/index.ts @@ -1,14 +1,14 @@ import { createPet, getPetById, listPets } from './api.js'; async function main(): Promise { - const pet = await getPetById(1); + const pet = await getPetById({ path: { id: 1 } }); // deepObject query param: the object is serialized as filter[name]=…&filter[status]=… - const filtered = await listPets({ filter: { name: 'rex', status: 'available' } }); + const filtered = await listPets({ query: { filter: { name: 'rex', status: 'available' } } }); // Bucket C: the create body is `Omit`, so the readOnly server-assigned // `id` is neither required nor accepted — this call compiles without it. - const created = await createPet({ name: 'rex', status: 'available' }); + const created = await createPet({ body: { name: 'rex', status: 'available' } }); // Bucket B: `metadata` is a free-form record (`{ [key: string]: unknown }`), so an // arbitrary key is accessible. Were it emitted as `{}`, this line would not compile. diff --git a/tests/e2e/generate-client/base.test.ts b/tests/e2e/generate-client/base.test.ts index fe38760c57..a303edf475 100644 --- a/tests/e2e/generate-client/base.test.ts +++ b/tests/e2e/generate-client/base.test.ts @@ -3,7 +3,7 @@ import { existsSync, readFileSync, rmSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { generate, killServer, repoRoot, startServer } from './helpers.js'; +import { generate, killServer, repoRoot, startServer, serverLog } from './helpers.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const fixture = join(__dirname, 'fixtures/base.yaml'); @@ -46,13 +46,13 @@ describe('generate-client base consumer (single-file output)', () => { const generated = readFileSync(generatedFile, 'utf-8'); expect(generated).toContain('export type Pet'); expect(generated).toContain('export class ApiError'); - // The descriptor wiring with the embedded runtime, plus flat call sugar per operation. + // The descriptor wiring with the embedded runtime, plus one binding per operation. expect(generated).toContain('// ─── Embedded runtime'); expect(generated).toContain('as const satisfies Record'); expect(generated).toContain('export const { configure, use } = client;'); - expect(generated).toContain('export const getPetById = (OPERATIONS, { serverUrl: "http://localhost:3102", clientHeader: "redocly-client-generator" });' @@ -110,8 +110,7 @@ describe('generate-client base consumer (single-file output)', () => { // Bucket C round-trip: createPet ran with a body that omits the readOnly `id`. expect(typeof parsed.created.name).toBe('string'); - const logResponse = await fetch(`${SERVER_BASE}/__test__/log`); - const log = (await logResponse.json()) as Array<{ method: string; url: string }>; + const log = await serverLog>(SERVER_BASE); expect(log).toContainEqual({ method: 'GET', url: '/pets/1' }); expect(log).toContainEqual({ method: 'POST', url: '/pets' }); expect( diff --git a/tests/e2e/generate-client/cafe-consumer/index-configure.ts b/tests/e2e/generate-client/cafe-consumer/index-configure.ts index 9a7f2013c2..ee03d27cb4 100644 --- a/tests/e2e/generate-client/cafe-consumer/index-configure.ts +++ b/tests/e2e/generate-client/cafe-consumer/index-configure.ts @@ -37,18 +37,22 @@ async function main(): Promise { // 1) Baseline: the file was generated with --server-url ${CAFE_BASE}, so the first // call should succeed against the mock server. - results.push(await step('initial-call-against-mock', () => listMenuItems({ limit: 1 }))); + results.push( + await step('initial-call-against-mock', () => listMenuItems({ query: { limit: 1 } })) + ); // 2) Flip serverUrl to an unreachable host. The same operation should now fail to // connect. This is the proof that configure() actually mutated the instance config. configure({ serverUrl: UNREACHABLE }); results.push( - await step('call-after-configure-to-unreachable', () => listMenuItems({ limit: 1 })) + await step('call-after-configure-to-unreachable', () => listMenuItems({ query: { limit: 1 } })) ); // 3) Flip serverUrl back to the live mock and confirm the config restored cleanly. configure({ serverUrl: liveBase }); - results.push(await step('call-after-configure-restored', () => listMenuItems({ limit: 1 }))); + results.push( + await step('call-after-configure-restored', () => listMenuItems({ query: { limit: 1 } })) + ); process.stdout.write(JSON.stringify(results, null, 2) + '\n'); } diff --git a/tests/e2e/generate-client/cafe-consumer/index.ts b/tests/e2e/generate-client/cafe-consumer/index.ts index e5a0ad2b7e..cfe0d5d9c3 100644 --- a/tests/e2e/generate-client/cafe-consumer/index.ts +++ b/tests/e2e/generate-client/cafe-consumer/index.ts @@ -1,5 +1,6 @@ import { ApiError, + client, createOrder, deleteMenuItem, deleteOrder, @@ -10,8 +11,6 @@ import { listOrderItems, listOrders, registerOAuth2Client, - setApiKey, - setBearer, updateOrder, createMenuItem, isBeverage, @@ -42,12 +41,12 @@ async function main(): Promise { // Set credentials once. Every OAuth2/bearer operation now sends // `Authorization: Bearer `, and every ApiKey operation sends the // `X-API-Key` header. Operations declared `security: []` send neither. - setBearer('test-bearer-token'); - setApiKey('test-api-key'); + client.auth.bearer('test-bearer-token'); + client.auth.apiKey('ApiKey', 'test-api-key'); results.push( await step('listMenuItems', () => - listMenuItems({ after: 'cursor1', limit: 5, sort: '-name', search: 'coffee' }) + listMenuItems({ query: { after: 'cursor1', limit: 5, sort: '-name', search: 'coffee' } }) ) ); @@ -59,18 +58,21 @@ async function main(): Promise { form.append('category', 'beverage'); form.append('volume', '250'); form.append('containsCaffeine', 'true'); - return createMenuItem(form); + return createMenuItem({ body: form }); }) ); results.push( - await step('deleteMenuItem', () => deleteMenuItem('prd_01h1s5z6vf2mm1mz3hevnn9va7')) + await step('deleteMenuItem', () => + deleteMenuItem({ path: { menuItemId: 'prd_01h1s5z6vf2mm1mz3hevnn9va7' } }) + ) ); results.push( await step('getMenuItemPhoto', async () => { - const result = await getMenuItemPhoto('prd_01h1s5z6vf2mm1mz3hevnn9va7', { - photoSize: 'medium', + const result = await getMenuItemPhoto({ + path: { menuItemId: 'prd_01h1s5z6vf2mm1mz3hevnn9va7' }, + query: { photoSize: 'medium' }, }); if (result instanceof Blob) { return { kind: 'blob', size: result.size, type: result.type }; @@ -79,49 +81,65 @@ async function main(): Promise { }) ); - results.push(await step('listOrders', () => listOrders({ filter: 'status:placed', limit: 5 }))); + results.push( + await step('listOrders', () => listOrders({ query: { filter: 'status:placed', limit: 5 } })) + ); results.push( await step('createOrder', () => createOrder({ - customerName: 'Ada Lovelace', - orderItems: [{ menuItemId: 'prd_01h1s5z6vf2mm1mz3hevnn9va7', quantity: 2 }], + body: { + customerName: 'Ada Lovelace', + orderItems: [{ menuItemId: 'prd_01h1s5z6vf2mm1mz3hevnn9va7', quantity: 2 }], + }, }) ) ); results.push( await step('getOrderById', () => - getOrderById('ord_01h1s5z6vf2mm1mz3hevnn9va7', { - 'X-Request-Id': '11111111-2222-3333-4444-555555555555', + getOrderById({ + path: { orderId: 'ord_01h1s5z6vf2mm1mz3hevnn9va7' }, + headers: { 'X-Request-Id': '11111111-2222-3333-4444-555555555555' }, }) ) ); results.push( await step('updateOrder', () => - updateOrder('ord_01h1s5z6vf2mm1mz3hevnn9va7', { status: OrderStatus.completed }) + updateOrder({ + path: { orderId: 'ord_01h1s5z6vf2mm1mz3hevnn9va7' }, + body: { status: OrderStatus.completed }, + }) ) ); - results.push(await step('deleteOrder', () => deleteOrder('ord_01h1s5z6vf2mm1mz3hevnn9va7'))); + results.push( + await step('deleteOrder', () => + deleteOrder({ path: { orderId: 'ord_01h1s5z6vf2mm1mz3hevnn9va7' } }) + ) + ); results.push( await step('listOrderItems', () => - listOrderItems({ filter: 'orderId:ord_01h1s5z6vf2mm1mz3hevnn9va7' }) + listOrderItems({ query: { filter: 'orderId:ord_01h1s5z6vf2mm1mz3hevnn9va7' } }) ) ); results.push( - await step('getRevenue', () => getRevenue({ startDate: '2026-01-01', endDate: '2026-01-31' })) + await step('getRevenue', () => + getRevenue({ query: { startDate: '2026-01-01', endDate: '2026-01-31' } }) + ) ); results.push( await step('registerOAuth2Client', () => registerOAuth2Client({ - name: 'demo-client', - scopes: ['menu:read', 'orders:read'], - grantTypes: ['client_credentials'], + body: { + name: 'demo-client', + scopes: ['menu:read', 'orders:read'], + grantTypes: ['client_credentials'], + }, }) ) ); @@ -130,7 +148,7 @@ async function main(): Promise { // type guards and confirm they agree with the raw discriminant. results.push( await step('menuItemGuards', async () => { - const list = await listMenuItems({}); + const list = await listMenuItems(); const item = list.items[0]; const category = (item as { category?: string }).category; const beverage = isBeverage(item); diff --git a/tests/e2e/generate-client/cafe.snapshot.ts b/tests/e2e/generate-client/cafe.snapshot.ts index 2ca73434f1..56cdc8edd5 100644 --- a/tests/e2e/generate-client/cafe.snapshot.ts +++ b/tests/e2e/generate-client/cafe.snapshot.ts @@ -414,7 +414,7 @@ export function isDessert(value: MenuItem): value is Dessert { export type ListMenuItemsResult = MenuItemList; -export type ListMenuItemsParams = { +export type ListMenuItemsQuery = { /** * Use the `endCursor` as a value for the `after` parameter to get the next page. */ @@ -466,7 +466,7 @@ export type ListMenuItemsParams = { }; export type ListMenuItemsVariables = { - params?: ListMenuItemsParams; + query?: ListMenuItemsQuery; }; export type CreateMenuItemResult = MenuItem; @@ -479,7 +479,7 @@ export type CreateMenuItemVariables = { export type DeleteMenuItemResult = void; -export type DeleteMenuItemVariables = { +export type DeleteMenuItemPath = { /** * ID of the menu item to retrieve. * @pattern ^prd_[0-9abcdefghjkmnpqrstvwxyz]{26}$ @@ -487,9 +487,21 @@ export type DeleteMenuItemVariables = { menuItemId: string; }; +export type DeleteMenuItemVariables = { + path: DeleteMenuItemPath; +}; + export type GetMenuItemPhotoResult = Blob | string; -export type GetMenuItemPhotoParams = { +export type GetMenuItemPhotoPath = { + /** + * ID of the menu item to retrieve. + * @pattern ^prd_[0-9abcdefghjkmnpqrstvwxyz]{26}$ + */ + menuItemId: string; +}; + +export type GetMenuItemPhotoQuery = { /** * Photo size to retrieve. */ @@ -497,17 +509,13 @@ export type GetMenuItemPhotoParams = { }; export type GetMenuItemPhotoVariables = { - /** - * ID of the menu item to retrieve. - * @pattern ^prd_[0-9abcdefghjkmnpqrstvwxyz]{26}$ - */ - menuItemId: string; - params?: GetMenuItemPhotoParams; + path: GetMenuItemPhotoPath; + query?: GetMenuItemPhotoQuery; }; export type ListOrdersResult = OrderList; -export type ListOrdersParams = { +export type ListOrdersQuery = { /** * Filters the collection items using space-separated `field:value` pairs. * @@ -559,7 +567,7 @@ export type ListOrdersParams = { }; export type ListOrdersVariables = { - params?: ListOrdersParams; + query?: ListOrdersQuery; }; export type CreateOrderResult = Order; @@ -572,6 +580,14 @@ export type CreateOrderVariables = { export type GetOrderByIdResult = Order; +export type GetOrderByIdPath = { + /** + * ID of the order to retrieve. + * @pattern ^ord_[0-9abcdefghjkmnpqrstvwxyz]{26}$ + */ + orderId: string; +}; + export type GetOrderByIdHeaders = { /** * Optional client-supplied correlation ID, echoed in logs and traces. @@ -581,17 +597,27 @@ export type GetOrderByIdHeaders = { }; export type GetOrderByIdVariables = { + path: GetOrderByIdPath; + headers?: GetOrderByIdHeaders; +}; + +export type DeleteOrderResult = void; + +export type DeleteOrderPath = { /** * ID of the order to retrieve. * @pattern ^ord_[0-9abcdefghjkmnpqrstvwxyz]{26}$ */ orderId: string; - headers?: GetOrderByIdHeaders; }; -export type DeleteOrderResult = void; - export type DeleteOrderVariables = { + path: DeleteOrderPath; +}; + +export type UpdateOrderResult = Order; + +export type UpdateOrderPath = { /** * ID of the order to retrieve. * @pattern ^ord_[0-9abcdefghjkmnpqrstvwxyz]{26}$ @@ -599,24 +625,18 @@ export type DeleteOrderVariables = { orderId: string; }; -export type UpdateOrderResult = Order; - export type UpdateOrderBody = { status: OrderStatus; }; export type UpdateOrderVariables = { - /** - * ID of the order to retrieve. - * @pattern ^ord_[0-9abcdefghjkmnpqrstvwxyz]{26}$ - */ - orderId: string; + path: UpdateOrderPath; body?: UpdateOrderBody; }; export type ListOrderItemsResult = OrderItem[]; -export type ListOrderItemsParams = { +export type ListOrderItemsQuery = { /** * Filters the collection items using space-separated `field:value` pairs. * @@ -638,12 +658,12 @@ export type ListOrderItemsParams = { }; export type ListOrderItemsVariables = { - params?: ListOrderItemsParams; + query?: ListOrderItemsQuery; }; export type GetRevenueResult = RevenueStatistics; -export type GetRevenueParams = { +export type GetRevenueQuery = { /** * Start date for the revenue calculation period (ISO 8601 datetime format). * Defaults to 30 days ago if not provided. @@ -659,7 +679,7 @@ export type GetRevenueParams = { }; export type GetRevenueVariables = { - params?: GetRevenueParams; + query?: GetRevenueQuery; }; export type RegisterOAuth2ClientResult = OAuth2Client; @@ -677,7 +697,7 @@ export type RegisterOAuth2ClientVariables = { export type Ops = { listMenuItems: { args: { - params?: ListMenuItemsParams; + query?: ListMenuItemsQuery; }; result: ListMenuItemsResult; }; @@ -689,28 +709,20 @@ export type Ops = { }; deleteMenuItem: { args: { - /** - * ID of the menu item to retrieve. - * @pattern ^prd_[0-9abcdefghjkmnpqrstvwxyz]{26}$ - */ - menuItemId: string; + path: DeleteMenuItemPath; }; result: DeleteMenuItemResult; }; getMenuItemPhoto: { args: { - /** - * ID of the menu item to retrieve. - * @pattern ^prd_[0-9abcdefghjkmnpqrstvwxyz]{26}$ - */ - menuItemId: string; - params?: GetMenuItemPhotoParams; + path: GetMenuItemPhotoPath; + query?: GetMenuItemPhotoQuery; }; result: GetMenuItemPhotoResult; }; listOrders: { args: { - params?: ListOrdersParams; + query?: ListOrdersQuery; }; result: ListOrdersResult; }; @@ -722,45 +734,33 @@ export type Ops = { }; getOrderById: { args: { - /** - * ID of the order to retrieve. - * @pattern ^ord_[0-9abcdefghjkmnpqrstvwxyz]{26}$ - */ - orderId: string; + path: GetOrderByIdPath; headers?: GetOrderByIdHeaders; }; result: GetOrderByIdResult; }; deleteOrder: { args: { - /** - * ID of the order to retrieve. - * @pattern ^ord_[0-9abcdefghjkmnpqrstvwxyz]{26}$ - */ - orderId: string; + path: DeleteOrderPath; }; result: DeleteOrderResult; }; updateOrder: { args: { - /** - * ID of the order to retrieve. - * @pattern ^ord_[0-9abcdefghjkmnpqrstvwxyz]{26}$ - */ - orderId: string; + path: UpdateOrderPath; body?: UpdateOrderBody; }; result: UpdateOrderResult; }; listOrderItems: { args: { - params?: ListOrderItemsParams; + query?: ListOrderItemsQuery; }; result: ListOrderItemsResult; }; getRevenue: { args: { - params?: GetRevenueParams; + query?: GetRevenueQuery; }; result: GetRevenueResult; }; @@ -875,6 +875,12 @@ export type OperationDescriptor = { /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */ security?: readonly (readonly SecuritySpec[])[]; pagination?: PaginationSpec; + /** + * `'grouped'` marks an operation that takes its inputs namespaced by layer even on a + * `argsStyle: 'flat'` client — the generator sets it where a merged call could not carry + * one name for two layers, and the operation's own input type says the same. + */ + argsStyle?: 'grouped'; /** * Declared success-response headers for throw-mode `{ envelope: true }`. * `name` is the lowercased wire name; `key` is the camelCase envelope property. @@ -1003,6 +1009,12 @@ export type ClientConfig = { auth?: AuthCredentials; /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */ errorMode?: 'throw' | 'result'; + /** + * How each call spells its inputs: `'grouped'` (the default) namespaces them by layer — + * `{ path, query, headers, cookies, body }` — and `'flat'` takes one merged object. + * Fixed at generate time, like `errorMode`, because it shapes the static types. + */ + argsStyle?: 'grouped' | 'flat'; onRequest?: Middleware['onRequest']; onResponse?: Middleware['onResponse']; onError?: Middleware['onError']; @@ -1762,14 +1774,66 @@ type Capabilities = SendCapabilities & { }; }; -/** The grouped args wire shape: path params by name plus the `params`/`body`/`headers`/`cookies` slots. */ +/** + * One call's inputs, namespaced by transport layer. `argsStyle: 'flat'` clients accept the + * merged form instead (every parameter and body property at one level) — `namespaceArgs` + * converts it to this shape before anything downstream reads it. + */ type OperationArgs = { - params?: Record; + path?: Record; + query?: Record; body?: unknown; headers?: Record; cookies?: Record; } & Record; +/** The five layer keys, and the only top-level keys a namespaced call may carry. */ +const LAYERS: readonly string[] = ['path', 'query', 'body', 'headers', 'cookies']; + +/** Where a declared parameter's `in` value puts it. */ +const LAYER_OF: Record = { + path: 'path', + query: 'query', + header: 'headers', + cookie: 'cookies', +}; + +/** + * Merged (`argsStyle: 'flat'`) args → the namespaced shape. A key that names a declared + * parameter goes to that parameter's layer; anything else is a property of the request + * body, which is how a flat call spells an object body. `body` stays reserved for the + * operations a flat call cannot merge (an array, a scalar, or a binary body). + */ +function namespaceArgs(op: OperationDescriptor, args: OperationArgs): OperationArgs { + const layers: Record> = {}; + let body: unknown; + let properties: Record | undefined; + const layerOfParam = new Map((op.params ?? []).map((param) => [param.name, param.in])); + for (const [key, value] of Object.entries(args)) { + const layer = LAYER_OF[layerOfParam.get(key) ?? '']; + if (layer !== undefined) { + (layers[layer] ??= {})[key] = value; + } else if (key === 'body' && op.body !== undefined) { + body = value; + } else if (op.body !== undefined) { + (properties ??= {})[key] = value; + } else { + throw new TypeError( + `Unknown argument "${key}" for operation "${op.id}": it names no declared parameter, and the operation takes no request body.` + ); + } + } + const namespaced: OperationArgs = {}; + if (layers.path) namespaced.path = layers.path; + // The flat surface types every query value, so the collected bag is one by construction. + if (layers.query) namespaced.query = layers.query as Record; + if (layers.headers) namespaced.headers = layers.headers; + if (layers.cookies) namespaced.cookies = layers.cookies; + if (properties !== undefined) namespaced.body = properties; + else if (body !== undefined) namespaced.body = body; + return namespaced; +} + /** The response reader implied by the descriptor (before any per-call `parseAs` override). */ /** * The `Accept` header matching how the response will be read — a blob/text operation @@ -1792,31 +1856,35 @@ function kindFor(op: OperationDescriptor): ParseAs | 'void' { return 'auto'; } -/** Route the grouped args by the descriptor: path values, query object, body, extra headers, cookies. */ +/** + * The call's inputs in namespaced form, converting first on a flat-style client. An + * operation the generator marked `argsStyle: 'grouped'` is already namespaced — its names + * could not be merged, so its input type never offered the flat shape. + */ +function inputOf( + op: OperationDescriptor, + args: OperationArgs, + config: ClientConfig +): OperationArgs { + const merged = config.argsStyle === 'flat' && op.argsStyle !== 'grouped'; + return merged ? namespaceArgs(op, args) : args; +} + +/** Route the namespaced args to the request pieces. */ function splitArgs(op: OperationDescriptor, args: OperationArgs) { - const path: Record = {}; - const pathNames = new Set(); - for (const param of op.params ?? []) { - if (param.in === 'path') { - pathNames.add(param.name); - path[param.name] = args[param.name]; - } - } - // An unknown top-level key can only be a bug (usually a flat-style call shape passed - // to a grouped client: `{ limit: 10 }` instead of `{ params: { limit: 10 } }`). - // TypeScript catches it, but transpilers that skip type-checking would otherwise - // ship a request that silently drops the value — fail the call loudly instead. + // An unknown layer key can only be a bug (usually flat-style args on a namespaced + // client). TypeScript catches it, but a transpiler that skips type-checking would + // otherwise ship a request that silently drops the value — fail the call loudly. for (const key of Object.keys(args)) { - if (key === 'params' || key === 'body' || key === 'headers' || key === 'cookies') continue; - if (pathNames.has(key)) continue; - throw new TypeError( - `Unknown argument "${key}" for operation "${op.id}". Query parameters go under params: { … } and the request body under body; valid keys are params, body, headers, cookies` + - (pathNames.size > 0 ? `, and the path parameters (${[...pathNames].join(', ')}).` : '.') - ); + if (!LAYERS.includes(key)) { + throw new TypeError( + `Unknown argument "${key}" for operation "${op.id}". Inputs are grouped by layer: ${LAYERS.join(', ')}.` + ); + } } return { - path, - query: args.params, + path: args.path ?? {}, + query: args.query, body: args.body, headers: args.headers, cookies: args.cookies, @@ -2093,7 +2161,8 @@ function createClientCore< for (const [name, op] of Object.entries(operations)) { if (op.responseKind === 'sse') { - const method = (args: OperationArgs = {}, init: SseOptions = {}) => { + const method = (given: OperationArgs = {}, init: SseOptions = {}) => { + const args = inputOf(op, given, config); if (!caps.sse) { throw new Error(`SSE capability not wired: cannot stream operation "${op.id}"`); } @@ -2117,8 +2186,13 @@ function createClientCore< Object.defineProperty(method, 'operationId', { value: op.id }); client[name] = method; } else { - const method = (args: OperationArgs = {}, init: RequestOptions = {}) => + // `raw` takes namespaced args; `method` is the public entry that accepts whichever + // style the client was generated with. The iterators namespace once and then drive + // `raw`, so a flat call is never converted twice. + const raw = (args: OperationArgs = {}, init: RequestOptions = {}) => execute(config, op, args, init, caps); + const method = (args: OperationArgs = {}, init: RequestOptions = {}) => + raw(inputOf(op, args, config), init); Object.defineProperty(method, 'name', { value: name }); Object.defineProperty(method, 'operationId', { value: op.id }); const spec = op.pagination; @@ -2137,31 +2211,42 @@ function createClientCore< pages: (args?: OperationArgs, init?: RequestOptions) => paginateCapability(caps, op).pagesByLink( linkPageCall(config, op, caps), - args, + inputOf(op, args ?? {}, config), init ), items: (args?: OperationArgs, init?: RequestOptions) => paginateCapability(caps, op).itemsByLink( linkPageCall(config, op, caps), spec, - args, + inputOf(op, args ?? {}, config), init ), }) : Object.assign(method, { pages: (args?: OperationArgs, init?: RequestOptions) => - paginateCapability(caps, op).pages(pageCall(method, config), spec, args, init), + paginateCapability(caps, op).pages( + pageCall(raw, config), + spec, + inputOf(op, args ?? {}, config), + init + ), items: (args?: OperationArgs, init?: RequestOptions) => - paginateCapability(caps, op).items(pageCall(method, config), spec, args, init), + paginateCapability(caps, op).items( + pageCall(raw, config), + spec, + inputOf(op, args ?? {}, config), + init + ), }); } } // Core members are assigned AFTER the operation loop — they win over colliding op names. client.configure = (next: ClientConfig): void => { - // `errorMode` is fixed at generate time (it shapes the static types); flipping it at - // runtime would silently desync return shapes from `Client`, so it is ignored. - const { errorMode: _fixed, auth, ...rest } = next; + // `errorMode` and `argsStyle` are fixed at generate time (they shape the static types); + // flipping either at runtime would silently desync the calls from `Client`, so both + // are ignored here. + const { errorMode: _fixedMode, argsStyle: _fixedStyle, auth, ...rest } = next; Object.assign(config, rest); // `auth` merges into existing credentials (like the `auth.*` setters) rather than // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set @@ -2214,160 +2299,4 @@ export function createClient< export const client = createClient(OPERATIONS, { serverUrl: "https://api.cafe.redocly.com", clientHeader: "redocly-client-generator" }); export const { configure, use } = client; -export const setBearer = client.auth.bearer; -export const setApiKey = (value: TokenProvider) => client.auth.apiKey("ApiKey", value); -export const listMenuItems = (params: { - /** - * Use the `endCursor` as a value for the `after` parameter to get the next page. - */ - after?: string; - /** - * Use the `startCursor` as a value for the `before` parameter to get the next page. - */ - before?: string; - /** - * To sort by id in descending order use `-id`. - * To sort by id in ascending order use `id`. - */ - sort?: string; - /** - * Filters the collection items using space-separated `field:value` pairs. - * - * **Format:** `field1:value1 field2:value2` - * - * **Supported operators:** - * - `field:value` - Exact match - * - `field:value1,value2` - Match any of the comma-separated values (OR) - * - Time ranges: Use `30d` (30 days), `7d` (7 days), `1h` (1 hour), etc. - * - * **Examples:** - * - `status:placed` - Filter by single status. - * - `status:placed,completed` - Filter by multiple statuses. - * - `createdAt:30d` - Filter orders created in the last 30 days. - * - `orderId:ord_01h1s5z6vf2mm1mz3hevnn9va7` - Filter by specific order ID. - * - `status:placed createdAt:7d` - Combine multiple filters. - */ - filter?: string; - /** - * Performs a case-insensitive text search across relevant fields in the collection. - * - * **Fields searched depend on the endpoint:** - * - **Menu items:** `name`, `photoTextDescription` - * - **Orders:** `customerName`, `id` - * - * Returns items where any of the searchable fields contain the search term as a substring. - */ - search?: string; - /** - * Use to return a number of results per page. - * If there is more data, use in combination with `after` to page through the data. - * @minimum 1 - * @maximum 100 - */ - limit?: number; -} = {}, init?: I): Promise, I>> => client.listMenuItems({ params }, init) as Promise, I>>; -export const createMenuItem = (body: FormData, init?: I): Promise, I>> => client.createMenuItem({ body }, init) as Promise, I>>; -export const deleteMenuItem = (menuItemId: string, init?: I): Promise, I>> => client.deleteMenuItem({ menuItemId }, init) as Promise, I>>; -export const getMenuItemPhoto = (menuItemId: string, params: { - /** - * Photo size to retrieve. - */ - photoSize?: "thumbnail" | "medium" | "large"; -} = {}, init?: I): Promise, I>> => client.getMenuItemPhoto({ menuItemId, params }, init) as Promise, I>>; -export const listOrders = (params: { - /** - * Filters the collection items using space-separated `field:value` pairs. - * - * **Format:** `field1:value1 field2:value2` - * - * **Supported operators:** - * - `field:value` - Exact match - * - `field:value1,value2` - Match any of the comma-separated values (OR) - * - Time ranges: Use `30d` (30 days), `7d` (7 days), `1h` (1 hour), etc. - * - * **Examples:** - * - `status:placed` - Filter by single status. - * - `status:placed,completed` - Filter by multiple statuses. - * - `createdAt:30d` - Filter orders created in the last 30 days. - * - `orderId:ord_01h1s5z6vf2mm1mz3hevnn9va7` - Filter by specific order ID. - * - `status:placed createdAt:7d` - Combine multiple filters. - */ - filter?: string; - /** - * To sort by id in descending order use `-id`. - * To sort by id in ascending order use `id`. - */ - sort?: string; - /** - * Use to return a number of results per page. - * If there is more data, use in combination with `after` to page through the data. - * @minimum 1 - * @maximum 100 - */ - limit?: number; - /** - * Use the `endCursor` as a value for the `after` parameter to get the next page. - */ - after?: string; - /** - * Use the `startCursor` as a value for the `before` parameter to get the next page. - */ - before?: string; - /** - * Performs a case-insensitive text search across relevant fields in the collection. - * - * **Fields searched depend on the endpoint:** - * - **Menu items:** `name`, `photoTextDescription` - * - **Orders:** `customerName`, `id` - * - * Returns items where any of the searchable fields contain the search term as a substring. - */ - search?: string; -} = {}, init?: I): Promise, I>> => client.listOrders({ params }, init) as Promise, I>>; -export const createOrder = (body: Omit, init?: I): Promise, I>> => client.createOrder({ body }, init) as Promise, I>>; -export const getOrderById = (orderId: string, headers: { - /** - * Optional client-supplied correlation ID, echoed in logs and traces. - * @format uuid - */ - "X-Request-Id"?: string; -} = {}, init?: I): Promise, I>> => client.getOrderById({ orderId, headers }, init) as Promise, I>>; -export const deleteOrder = (orderId: string, init?: I): Promise, I>> => client.deleteOrder({ orderId }, init) as Promise, I>>; -export const updateOrder = (orderId: string, body?: { - status: OrderStatus; -}, init?: I): Promise, I>> => client.updateOrder({ orderId, body }, init) as Promise, I>>; -export const listOrderItems = (params: { - /** - * Filters the collection items using space-separated `field:value` pairs. - * - * **Format:** `field1:value1 field2:value2` - * - * **Supported operators:** - * - `field:value` - Exact match - * - `field:value1,value2` - Match any of the comma-separated values (OR) - * - Time ranges: Use `30d` (30 days), `7d` (7 days), `1h` (1 hour), etc. - * - * **Examples:** - * - `status:placed` - Filter by single status. - * - `status:placed,completed` - Filter by multiple statuses. - * - `createdAt:30d` - Filter orders created in the last 30 days. - * - `orderId:ord_01h1s5z6vf2mm1mz3hevnn9va7` - Filter by specific order ID. - * - `status:placed createdAt:7d` - Combine multiple filters. - */ - filter?: string; -} = {}, init?: I): Promise, I>> => client.listOrderItems({ params }, init) as Promise, I>>; -export const getRevenue = (params: { - /** - * Start date for the revenue calculation period (ISO 8601 datetime format). - * Defaults to 30 days ago if not provided. - * @format date - */ - startDate?: string; - /** - * End date for the revenue calculation period (ISO 8601 datetime format). - * Defaults to current time if not provided. - * @format date - */ - endDate?: string; -} = {}, init?: I): Promise, I>> => client.getRevenue({ params }, init) as Promise, I>>; -export const registerOAuth2Client = (body: RegisterClientObject, init?: I): Promise, I>> => client.registerOAuth2Client({ body }, init) as Promise, I>>; +export const { listMenuItems, createMenuItem, deleteMenuItem, getMenuItemPhoto, listOrders, createOrder, getOrderById, deleteOrder, updateOrder, listOrderItems, getRevenue, registerOAuth2Client } = client; diff --git a/tests/e2e/generate-client/cafe.test.ts b/tests/e2e/generate-client/cafe.test.ts index f204116807..d94c8df915 100644 --- a/tests/e2e/generate-client/cafe.test.ts +++ b/tests/e2e/generate-client/cafe.test.ts @@ -3,7 +3,7 @@ import { existsSync, readFileSync, rmSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { cliEntry, killServer, repoRoot, startServer } from './helpers.js'; +import { cliEntry, killServer, repoRoot, startServer, serverLog } from './helpers.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const fixture = join(__dirname, 'fixtures/cafe.yaml'); @@ -106,8 +106,7 @@ describe('generate-client end-to-end (cafe.yaml)', () => { } results = JSON.parse(run.stdout.trim()) as StepResult[]; - const logResponse = await fetch(`${SERVER_BASE}/__test__/log`); - log = (await logResponse.json()) as LogEntry[]; + log = await serverLog(SERVER_BASE); }, 120_000); afterAll(async () => { @@ -134,7 +133,7 @@ describe('generate-client end-to-end (cafe.yaml)', () => { expect(generated).toContain('export type OAuth2Client = {'); }); - test('generated file declares one flat call-sugar function per operation', () => { + test('generated file exports one binding per operation', () => { const expected = [ 'listMenuItems', 'createMenuItem', @@ -143,16 +142,14 @@ describe('generate-client end-to-end (cafe.yaml)', () => { 'listOrders', 'createOrder', 'getOrderById', - 'updateOrder', 'deleteOrder', + 'updateOrder', 'listOrderItems', 'getRevenue', 'registerOAuth2Client', ]; - for (const name of expected) { - // Plain arrow, generic envelope-aware arrow, or Object.assign-wrapped (paginated). - expect(generated).toMatch(new RegExp(`export const ${name} = (Object\\.assign\\()?[(<]`)); - } + // One destructure of the client: the exported name IS the method. + expect(generated).toContain(`export const { ${expected.join(', ')} } = client;`); }); test('exports an OPERATIONS descriptor map keyed by operationId (method + path template)', () => { @@ -178,18 +175,18 @@ describe('generate-client end-to-end (cafe.yaml)', () => { expect(generated).toContain('export const { configure, use } = client;'); }); - test('generated file uses ergonomic signatures (positional path params + params object + body)', () => { - // Throw-mode flat sugar is generic over `init` (envelope-aware return type). - const sugar = ''; - expect(generated).toContain(`export const deleteMenuItem = ${sugar}(menuItemId: string,`); - expect(generated).toContain(`export const getMenuItemPhoto = ${sugar}(menuItemId: string,`); - expect(generated).toContain(`export const updateOrder = ${sugar}(orderId: string,`); - expect(generated).toContain(`export const listMenuItems = ${sugar}(params:`); + test('inputs are grouped by layer, one type per layer', () => { + expect(generated).toContain('export type DeleteMenuItemPath = {'); + expect(generated).toContain('export type GetMenuItemPhotoVariables = {'); + expect(generated).toContain(' path: GetMenuItemPhotoPath;'); + expect(generated).toContain(' query?: GetMenuItemPhotoQuery;'); + expect(generated).toContain('export type UpdateOrderVariables = {'); + expect(generated).toContain('export type ListMenuItemsQuery = {'); // readOnly fields are dropped from the create body (Bucket C). expect(generated).toContain( - `export const createOrder = ${sugar}(body: Omit,` + 'export type CreateOrderBody = Omit;' ); - expect(generated).toContain(`export const createMenuItem = ${sugar}(body: FormData,`); + expect(generated).toContain('export type CreateMenuItemBody = FormData;'); }); // Named string enums get a runtime const-object companion by default, which the @@ -275,10 +272,10 @@ describe('generate-client end-to-end (cafe.yaml)', () => { expect(entry!.headers['x-request-id']).toBe('11111111-2222-3333-4444-555555555555'); }); - // The consumer calls setBearer()/setApiKey() once; every OAuth2 operation must + // The consumer sets each credential once on the instance; every OAuth2 operation must // then carry the bearer header, every ApiKey operation the X-API-Key header, // and `security: []` operations neither. - test('setBearer() injects Authorization on OAuth2 operations (getOrderById)', () => { + test('a bearer credential injects Authorization on OAuth2 operations (getOrderById)', () => { const entry = log.find( (e) => e.method === 'GET' && e.url === '/orders/ord_01h1s5z6vf2mm1mz3hevnn9va7' ); @@ -286,7 +283,7 @@ describe('generate-client end-to-end (cafe.yaml)', () => { expect(entry!.headers['authorization']).toBe('Bearer test-bearer-token'); }); - test('setApiKey() injects X-API-Key on ApiKey operations (getRevenue)', () => { + test('an apiKey credential injects X-API-Key on ApiKey operations (getRevenue)', () => { const entry = log.find((e) => e.method === 'GET' && e.url.startsWith('/revenue')); expect(entry).toBeDefined(); expect(entry!.headers['x-api-key']).toBe('test-api-key'); diff --git a/tests/e2e/generate-client/cli-compose.test.ts b/tests/e2e/generate-client/cli-compose.test.ts new file mode 100644 index 0000000000..4adddf3956 --- /dev/null +++ b/tests/e2e/generate-client/cli-compose.test.ts @@ -0,0 +1,256 @@ +// Composition of generated CLIs: the generated module is importable (no side effects), +// two descriptions compose behind namespaces with their own credentials, and a custom +// command with a handler joins them at the root — the login story, built in user land. +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { cliEntry, generate, repoRoot, tsxBin } from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +let dir: string; + +vi.setConfig({ testTimeout: 120_000 }); + +function runEntry(args: string[], env: Record = {}) { + const result = spawnSync(tsxBin, [join(dir, 'cafe.ts'), ...args], { + cwd: dir, + encoding: 'utf-8', + env: { ...process.env, ...env }, + }); + return { code: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'cli-compose-')); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); + symlinkSync(join(repoRoot, 'node_modules'), join(dir, 'node_modules'), 'dir'); + // Two descriptions — the same fixture twice is exactly the collision case: every + // operationId exists in both, so only namespacing can tell them apart. + generate(join(__dirname, 'fixtures/cli.yaml'), join(dir, 'shop.client.ts'), [ + '--generator', + 'cli', + '--import-ext', + 'ts', + ]); + generate(join(__dirname, 'fixtures/cli.yaml'), join(dir, 'kitchen.client.ts'), [ + '--generator', + 'cli', + '--import-ext', + 'ts', + ]); + // The user-land entry: everything the extension design promises, in ~20 lines. + writeFileSync( + join(dir, 'cafe.ts'), + `import { runCli, type CustomCommand } from '@redocly/client-generator'; +import * as shop from './shop.client.cli.ts'; +import * as kitchen from './kitchen.client.cli.ts'; + +const login: CustomCommand = { + name: 'login', + summary: 'Store a token for both APIs.', + flags: [{ name: 'user', param: 'user', type: 'string', required: true }], + handler: (context) => { + context.wiring.stdout(JSON.stringify({ loggedIn: context.params.user })); + return 0; + }, +}; + +process.exit( + await runCli( + [ + { commands: [login], wiring: shop.wiring }, + { namespace: 'shop', commands: shop.COMMANDS, wiring: { ...shop.wiring, envPrefix: 'CAFE_SHOP' } }, + { namespace: 'kitchen', commands: kitchen.COMMANDS, wiring: { ...kitchen.wiring, envPrefix: 'CAFE_KITCHEN' } }, + ], + process.argv.slice(2) + ) +); +`, + 'utf-8' + ); +}); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('composed CLI (end-to-end)', () => { + it('importing the generated modules runs nothing; the entry composes them', () => { + const help = runEntry(['--help']); + expect(help.code, help.stderr).toBe(0); + expect(help.stdout).toContain('shop'); + expect(help.stdout).toContain('kitchen'); + expect(help.stdout).toContain('login Store a token for both APIs.'); + }); + + it('routes a namespace to its source with its own credential prefix', () => { + const dry = runEntry(['shop', 'orders', 'getOrder', 'ord_1', '--dry-run'], { + CAFE_SHOP_TOKEN: 'shop-secret', + }); + expect(dry.code, dry.stderr).toBe(0); + const captured = JSON.parse(dry.stdout); + expect(captured.url).toContain('/orders/ord_1'); + // The credential arrived (and was redacted) — proving the per-source prefix works. + expect(JSON.stringify(captured)).not.toContain('shop-secret'); + expect(captured.headers.Authorization).toBe('***'); + }); + + it('takes an operationId without its group, the form the guide shows', () => { + // `cafe shop listOrders` — the alias, then a bare operationId, because the name is + // unambiguous inside that api. The guide documents this shorter form. + const dry = runEntry(['shop', 'getOrder', 'ord_2', '--dry-run'], { + CAFE_SHOP_TOKEN: 'shop-secret', + }); + expect(dry.code, dry.stderr).toBe(0); + expect(JSON.parse(dry.stdout).url).toContain('/orders/ord_2'); + }); + + it('namespace help shows that API; the same operationId lives in both namespaces', () => { + const shop = runEntry(['shop', '--help']); + const kitchen = runEntry(['kitchen', '--help']); + expect(shop.code).toBe(0); + expect(kitchen.code).toBe(0); + expect(shop.stdout).toContain('orders'); + expect(kitchen.stdout).toContain('orders'); + }); + + it('the root custom command runs with parsed flags', () => { + const login = runEntry(['login', '--user', 'sam']); + expect(login.code, login.stderr).toBe(0); + expect(JSON.parse(login.stdout)).toEqual({ loggedIn: 'sam' }); + }); + + it('an unknown namespace is a usage error naming the real ones', () => { + const bad = runEntry(['warehouse', 'listOrders']); + expect(bad.code).toBe(4); + const message = JSON.parse(bad.stderr).error.message; + expect(message).toContain('shop'); + expect(message).toContain('kitchen'); + }); + + it('each generated CLI still works standalone', () => { + const standalone = spawnSync(tsxBin, [join(dir, 'shop.client.cli.ts'), '--help'], { + cwd: dir, + encoding: 'utf-8', + }); + expect(standalone.status, standalone.stderr).toBe(0); + expect(standalone.stdout).toContain('Usage:'); + }); +}); + +describe('config-driven composition (client.cliOutput)', () => { + let project: string; + + beforeAll(() => { + project = mkdtempSync(join(tmpdir(), 'cli-output-')); + writeFileSync(join(project, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); + symlinkSync(join(repoRoot, 'node_modules'), join(project, 'node_modules'), 'dir'); + const fixture = join(__dirname, 'fixtures/cli.yaml'); + writeFileSync( + join(project, 'redocly.yaml'), + [ + 'extends: []', + 'client:', + // A directory nothing else creates — the composed entry makes its own. + ' cliOutput: ./bin/cafe.ts', + ' importExt: ts', + ' generators: [typescript, zod, cli]', + 'apis:', + ` shop: { root: ${fixture}, clientOutput: ./src/shop.ts }`, + ` kitchen: { root: ${fixture}, clientOutput: ./src/kitchen.ts }`, + '', + ].join('\n'), + 'utf-8' + ); + // Eject the cli generator first: composition keys off the emitted module, so a + // `./generators/cli.mjs` path entry must compose exactly like the built-in name. + const ejected = spawnSync( + 'node', + [cliEntry, 'eject-generator', 'cli', '--config', join(project, 'redocly.yaml')], + { cwd: project, encoding: 'utf-8' } + ); + expect(ejected.status, ejected.stderr).toBe(0); + expect(readFileSync(join(project, 'redocly.yaml'), 'utf-8')).toContain( + 'generators: [typescript, zod, ./generators/cli.mjs]' + ); + const generated = spawnSync( + 'node', + [cliEntry, 'generate-client', '--config', join(project, 'redocly.yaml')], + { cwd: project, encoding: 'utf-8' } + ); + expect(generated.status, generated.stderr).toBe(0); + }); + + afterAll(() => { + rmSync(project, { recursive: true, force: true }); + }); + + it('one generate run emits the composed entry over every api that selected cli', () => { + const help = spawnSync(tsxBin, [join(project, 'bin/cafe.ts'), '--help'], { + cwd: project, + encoding: 'utf-8', + }); + expect(help.status, help.stderr).toBe(0); + // Run as a script, so help names the file without its extension — never `cafe.ts`, + // which is not a command anyone can type. + expect(help.stdout).toContain('Usage: cafe '); + expect(help.stdout).toContain('shop'); + expect(help.stdout).toContain('kitchen'); + }); + + it('routes a namespace and reads the alias-scoped credential', () => { + const dry = spawnSync( + tsxBin, + [join(project, 'bin/cafe.ts'), 'kitchen', 'orders', 'getOrder', 'ord_7', '--dry-run'], + { cwd: project, encoding: 'utf-8', env: { ...process.env, CAFE_KITCHEN_TOKEN: 'k-secret' } } + ); + expect(dry.status, dry.stderr).toBe(0); + const captured = JSON.parse(dry.stdout); + expect(captured.url).toContain('/orders/ord_7'); + expect(captured.headers.Authorization).toBe('***'); + expect(JSON.stringify(captured)).not.toContain('k-secret'); + }); +}); + +describe('client.cliOutput validation', () => { + const generateWith = (cliOutput: string) => { + const project = mkdtempSync(join(tmpdir(), 'cli-output-invalid-')); + const fixture = join(__dirname, 'fixtures/cli.yaml'); + writeFileSync( + join(project, 'redocly.yaml'), + [ + 'extends: []', + 'client:', + ` cliOutput: ${cliOutput}`, + ' generators: [typescript, zod, cli]', + 'apis:', + ` shop: { root: ${fixture}, clientOutput: ./src/shop.ts }`, + '', + ].join('\n'), + 'utf-8' + ); + const result = spawnSync( + 'node', + [cliEntry, 'generate-client', '--config', join(project, 'redocly.yaml')], + { cwd: project, encoding: 'utf-8' } + ); + rmSync(project, { recursive: true, force: true }); + return result; + }; + + it('rejects a non-.ts entry instead of writing TypeScript into it', () => { + const result = generateWith('./bin/cafe.js'); + expect(result.status).toBe(1); + expect(result.stderr).toContain('client.cliOutput must point at a TypeScript file'); + }); + + it('rejects an entry that lands on a file the run generated', () => { + const result = generateWith('./src/shop.cli.ts'); + expect(result.status).toBe(1); + expect(result.stderr).toContain('client.cliOutput resolves to a file this run generated'); + }); +}); diff --git a/tests/e2e/generate-client/cli-consumer/.gitignore b/tests/e2e/generate-client/cli-consumer/.gitignore new file mode 100644 index 0000000000..15450ac8ad --- /dev/null +++ b/tests/e2e/generate-client/cli-consumer/.gitignore @@ -0,0 +1,2 @@ +client/ +client-strip/ diff --git a/tests/e2e/generate-client/cli-consumer/server.ts b/tests/e2e/generate-client/cli-consumer/server.ts new file mode 100644 index 0000000000..f4ae155bec --- /dev/null +++ b/tests/e2e/generate-client/cli-consumer/server.ts @@ -0,0 +1,68 @@ +// Throwaway HTTP server for the cli e2e: canned cursor pages, an echo POST, and a +// request log (`/__test__/log`) so the test can assert query strings, auth headers, +// forwarded bodies, and hit counts. +import * as http from 'node:http'; + +const PORT = Number.parseInt(process.env.CLI_SERVER_PORT ?? '3108', 10); + +type LogEntry = { method: string; url: string; authorization?: string; body?: string }; +const requestLog: LogEntry[] = []; + +const server = http.createServer(async (req, res) => { + const method = req.method ?? 'GET'; + const url = req.url ?? '/'; + const { pathname, searchParams } = new URL(url, 'http://localhost'); + + if (pathname === '/__test__/ready') { + res.writeHead(200).end('ok'); + return; + } + if (pathname === '/__test__/log') { + res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(requestLog)); + return; + } + + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const body = chunks.length > 0 ? Buffer.concat(chunks).toString('utf-8') : undefined; + requestLog.push({ + method, + url, + ...(typeof req.headers.authorization === 'string' + ? { authorization: req.headers.authorization } + : {}), + ...(body !== undefined ? { body } : {}), + }); + + const json = (status: number, payload: unknown) => + res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(payload)); + + if (method === 'GET' && pathname === '/orders') { + if (searchParams.get('cursor') === 'page-2') { + json(200, { orders: [{ id: 'ord_2', item: 'tea', quantity: 1 }] }); + } else { + json(200, { + orders: [{ id: 'ord_1', item: 'espresso', quantity: 2 }], + nextCursor: 'page-2', + }); + } + return; + } + if (method === 'POST' && pathname === '/orders') { + json(201, { id: 'ord_new', ...JSON.parse(body ?? '{}') }); + return; + } + if (method === 'GET' && pathname.startsWith('/orders/')) { + json(200, { id: pathname.split('/').pop(), item: 'espresso', quantity: 2 }); + return; + } + if (method === 'GET' && pathname === '/ping') { + res.writeHead(204).end(); + return; + } + json(404, { message: 'not found' }); +}); + +server.listen(PORT, () => { + process.stdout.write(`cli e2e server on :${PORT}\n`); +}); diff --git a/tests/e2e/generate-client/cli.test.ts b/tests/e2e/generate-client/cli.test.ts new file mode 100644 index 0000000000..a9929b50d3 --- /dev/null +++ b/tests/e2e/generate-client/cli.test.ts @@ -0,0 +1,206 @@ +import { spawnSync, type ChildProcess } from 'node:child_process'; +import { existsSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + generate, + killServer, + serverLog as readServerLog, + startServer, + tsxBin, +} from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixture = join(__dirname, 'fixtures/cli.yaml'); +const consumerDir = join(__dirname, 'cli-consumer'); +const clientDir = join(consumerDir, 'client'); +const stripDir = join(consumerDir, 'client-strip'); + +const SERVER_PORT = 3108; +const SERVER_BASE = `http://127.0.0.1:${SERVER_PORT}`; + +// Every case below spawns the generated CLI through `tsx` (often several times), and +// TypeScript startup alone can approach the 5s default on a loaded machine. +vi.setConfig({ testTimeout: 120_000 }); + +/** Run the generated CLI with tsx; returns exit code + parsed streams. */ +function runCliBin(args: string[], env: Record = {}) { + const result = spawnSync(tsxBin, [join(clientDir, 'client.cli.ts'), ...args], { + cwd: clientDir, + encoding: 'utf-8', + env: { ...process.env, ...env }, + }); + return { code: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +function serverLog(): Promise< + Array<{ method: string; url: string; authorization?: string; body?: string }> +> { + return readServerLog(SERVER_BASE); +} + +describe('generate-client cli generator (end-to-end)', () => { + let serverProcess: ChildProcess | undefined; + + beforeAll(async () => { + generate(fixture, join(clientDir, 'client.ts'), [ + '--generator', + 'typescript', + '--generator', + 'zod', + '--generator', + 'cli', + ]); + writeFileSync(join(clientDir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); + // A second copy with `.ts` specifiers: what a zero-build `node` runner needs. + generate(fixture, join(stripDir, 'client.ts'), [ + '--generator', + 'typescript', + '--generator', + 'zod', + '--generator', + 'cli', + '--import-ext', + 'ts', + ]); + writeFileSync(join(stripDir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); + serverProcess = await startServer( + join(consumerDir, 'server.ts'), + consumerDir, + { CLI_SERVER_PORT: String(SERVER_PORT) }, + SERVER_BASE, + 'cli-e2e-server' + ); + }, 60_000); + + afterAll(async () => { + if (serverProcess) await killServer(serverProcess); + rmSync(clientDir, { recursive: true, force: true }); + rmSync(stripDir, { recursive: true, force: true }); + }); + + it('generates client.cli.ts and strict tsc (types: node) accepts it', () => { + expect(existsSync(join(clientDir, 'client.cli.ts'))).toBe(true); + writeFileSync( + join(clientDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + module: 'nodenext', + moduleResolution: 'nodenext', + target: 'es2022', + lib: ['ES2022', 'DOM'], + strict: true, + noEmit: true, + skipLibCheck: true, + types: ['node'], + }, + include: ['**/*.ts'], + }), + 'utf-8' + ); + const tsc = spawnSync(join(__dirname, '../../../node_modules/.bin/tsc'), ['-p', clientDir], { + encoding: 'utf-8', + }); + expect(tsc.status, `${tsc.stdout}\n${tsc.stderr}`).toBe(0); + }, 120_000); + + it('typed flags reach the query string; bearer auth comes from the env prefix', async () => { + const before = (await serverLog()).length; + const { code, stdout } = runCliBin( + ['orders', 'listOrders', '--status', 'open', '--limit', '2'], + { CLIENT_TOKEN: 'e2e-token' } + ); + expect(code).toBe(0); + expect(JSON.parse(stdout).orders).toHaveLength(1); + const entries = await serverLog(); + const hit = entries[entries.length - 1]; + expect(entries.length).toBe(before + 1); + expect(hit.url).toContain('status=open'); + expect(hit.url).toContain('limit=2'); + expect(hit.authorization).toBe('Bearer e2e-token'); + }); + + it('positional path params and --json bodies dispatch correctly', async () => { + const get = runCliBin(['orders', 'getOrder', 'ord_42']); + expect(get.code).toBe(0); + expect(JSON.parse(get.stdout).id).toBe('ord_42'); + + writeFileSync(join(clientDir, 'order.json'), '{"item":"latte","quantity":1}', 'utf-8'); + const create = runCliBin(['orders', 'createOrder', '--json', '@order.json']); + expect(create.code).toBe(0); + expect(JSON.parse(create.stdout)).toMatchObject({ id: 'ord_new', item: 'latte' }); + const entries = await serverLog(); + expect(entries[entries.length - 1].body).toBe('{"item":"latte","quantity":1}'); + }); + + it('zod validation failures exit 3 without hitting the server', async () => { + const before = (await serverLog()).length; + const { code, stderr } = runCliBin([ + 'orders', + 'createOrder', + '--json', + '{"item":"latte","quantity":0}', + ]); + expect(code).toBe(3); + expect(JSON.parse(stderr).error.code).toBe(3); + expect((await serverLog()).length).toBe(before); + }); + + it('--dry-run prints the prepared request and sends nothing; the token is redacted', async () => { + const before = (await serverLog()).length; + const { code, stdout } = runCliBin(['orders', 'getOrder', 'ord_1', '--dry-run'], { + CLIENT_TOKEN: 'secret-token', + }); + expect(code).toBe(0); + const captured = JSON.parse(stdout); + expect(captured.url).toContain('/orders/ord_1'); + expect(captured.method).toBe('GET'); + expect(JSON.stringify(captured)).not.toContain('secret-token'); + expect((await serverLog()).length).toBe(before); + }); + + it('--page-all follows the cursor and prints one JSON page per line', () => { + const { code, stdout } = runCliBin(['orders', 'listOrders', '--page-all']); + expect(code).toBe(0); + const pages = stdout + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(pages).toHaveLength(2); + expect(pages[0].orders[0].id).toBe('ord_1'); + expect(pages[1].orders[0].id).toBe('ord_2'); + }); + + it('schema prints the request schema; usage errors exit 4; --help exits 0', () => { + const schema = runCliBin(['schema', 'createOrder']); + expect(schema.code).toBe(0); + expect(JSON.parse(schema.stdout).request).toBeDefined(); + + const usage = runCliBin(['orders', 'listOrders', '--bogus', 'x']); + expect(usage.code).toBe(4); + expect(JSON.parse(usage.stderr).error.code).toBe(4); + + const help = runCliBin(['--help']); + expect(help.code).toBe(0); + expect(help.stdout).toContain('orders'); + }); + + it('runs under node type stripping with no build step, zod included', () => { + // Erasable TypeScript only: a constructor parameter property anywhere in the import + // graph (it was in the zod module's error class) breaks strip-only mode. + const result = spawnSync( + process.execPath, + ['--experimental-strip-types', join(stripDir, 'client.cli.ts'), '--help'], + { encoding: 'utf-8', cwd: consumerDir } + ); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain('Usage:'); + }); + + it('void results print nothing and exit 0', () => { + const { code, stdout } = runCliBin(['ping']); + expect(code).toBe(0); + expect(stdout.trim()).toBe(''); + }); +}); diff --git a/tests/e2e/generate-client/docs.test.ts b/tests/e2e/generate-client/docs.test.ts new file mode 100644 index 0000000000..e9c69ed6e2 --- /dev/null +++ b/tests/e2e/generate-client/docs.test.ts @@ -0,0 +1,122 @@ +// Reference documentation end-to-end: `--docs` is one switch for the whole run, and each +// generator documents ITSELF — so the bar is that every selected generator with a page +// produced one, that no page appears without the switch, and that each page describes the +// artifact beside it (the CLI page against the CLI's own `--help`, an SDK page against the +// call syntax that SDK generates). +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { generate, repoRoot, tsxBin } from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixture = join(__dirname, 'fixtures/cli.yaml'); + +let dir: string; +let cliPage: string; +let pythonPage: string; + +vi.setConfig({ testTimeout: 120_000 }); + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'client-docs-')); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); + // The generated CLI validates with zod, and this temp dir is outside the repo. + symlinkSync(join(repoRoot, 'node_modules'), join(dir, 'node_modules'), 'dir'); + generate(fixture, join(dir, 'cafe.client.ts'), [ + '--generator', + 'cli', + '--generator', + 'python', + '--docs', + ]); + cliPage = readFileSync(join(dir, 'cafe.client.cli.md'), 'utf-8'); + pythonPage = readFileSync(join(dir, 'cafe.client.python.md'), 'utf-8'); +}); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('generate-client --docs (end-to-end)', () => { + it('writes one page per selected generator that documents itself, and none for the rest', () => { + // cli and python asked for; typescript and zod came in as prerequisites of cli, and + // typescript documents itself too. zod has no page. + expect(existsSync(join(dir, 'cafe.client.cli.md'))).toBe(true); + expect(existsSync(join(dir, 'cafe.client.python.md'))).toBe(true); + expect(existsSync(join(dir, 'cafe.client.typescript.md'))).toBe(true); + expect(existsSync(join(dir, 'cafe.client.zod.md'))).toBe(false); + }); + + it('writes no page without the switch', () => { + const plain = mkdtempSync(join(tmpdir(), 'client-nodocs-')); + try { + generate(fixture, join(plain, 'c.ts'), ['--generator', 'python']); + expect(existsSync(join(plain, 'c.py'))).toBe(true); + expect(existsSync(join(plain, 'c.python.md'))).toBe(false); + } finally { + rmSync(plain, { recursive: true, force: true }); + } + }); + + it('documents every command the CLI dispatches, addressed exactly as --help shows it', () => { + const help = (args: string[]): string => { + const result = spawnSync(tsxBin, [join(dir, 'cafe.client.cli.ts'), ...args], { + cwd: dir, + encoding: 'utf-8', + }); + expect(result.status, result.stderr).toBe(0); + return result.stdout; + }; + const entries = (text: string): string[] => + text + .slice(text.indexOf('Commands:') + 'Commands:'.length, text.indexOf('Global flags:')) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line !== '') + .map((line) => line.split(/\s{2,}/)[0]); + + const addresses: string[] = []; + for (const entry of entries(help(['--help']))) { + if (entry.endsWith(' ')) { + addresses.push(...entries(help([entry.replace(' ', ''), '--help']))); + } else { + addresses.push(entry); + } + } + expect(addresses.length).toBeGreaterThan(3); + for (const address of addresses) { + expect(cliPage, `${address} is missing from the reference page`).toContain( + `### \`${address}\`` + ); + } + expect(cliPage).toContain('CAFE_CLIENT_TOKEN'); + expect(cliPage).toContain('| 3 | validation error |'); + }); + + it('shows each SDK page its own call syntax, taken from that generator', () => { + expect(pythonPage).toContain('```python'); + expect(pythonPage).toContain('client.list_orders('); + expect(pythonPage).toContain('| `status` | query |'); + expect(pythonPage).toContain('BearerAuth'); + // listOrders declares x-redoclyPagination, resolved by the helper the SDK uses. + expect(pythonPage).toContain('This operation is paginated'); + }); + + it('is well-formed Markdown: one H1, balanced fences, no tabs or trailing spaces', () => { + for (const page of [cliPage, pythonPage]) { + const lines = page.split('\n'); + expect(lines.filter((line) => line.startsWith('# '))).toHaveLength(1); + expect(lines.filter((line) => line.startsWith('```')).length % 2).toBe(0); + expect(page).not.toContain('\t'); + expect(lines.filter((line) => /\s$/.test(line))).toEqual([]); + for (let index = 1; index < lines.length; index++) { + if (lines[index].startsWith('|') && lines[index - 1] !== '') { + expect(lines[index - 1].startsWith('|')).toBe(true); + } + } + } + }); +}); diff --git a/tests/e2e/generate-client/eject.test.ts b/tests/e2e/generate-client/eject.test.ts new file mode 100644 index 0000000000..7485d6c37e --- /dev/null +++ b/tests/e2e/generate-client/eject.test.ts @@ -0,0 +1,269 @@ +import { spawnSync } from 'node:child_process'; +import { + appendFileSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { cliEntry, repoRoot, tsxBin } from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** A throwaway project where `@redocly/client-generator` resolves like a user install. */ +function makeProject(): string { + const dir = mkdtempSync(join(tmpdir(), 'eject-')); + copyFileSync(join(__dirname, 'fixtures/pagination.yaml'), join(dir, 'openapi.yaml')); + mkdirSync(join(dir, 'node_modules/@redocly'), { recursive: true }); + symlinkSync( + join(repoRoot, 'packages/client-generator'), + join(dir, 'node_modules/@redocly/client-generator') + ); + return dir; +} + +function run(cwd: string, args: string[]) { + return spawnSync('node', [cliEntry, ...args], { cwd, encoding: 'utf-8' }); +} + +describe('eject-generator (end-to-end)', () => { + let project: string; + + beforeAll(() => { + project = makeProject(); + }, 60_000); + + afterAll(() => { + rmSync(project, { recursive: true, force: true }); + }, 60_000); + + it('ejects php: the generator, both skills, a pointer beside the code; re-eject needs --force', () => { + const eject = run(project, ['eject-generator', 'php']); + expect(eject.status, eject.stderr).toBe(0); + expect(existsSync(join(project, 'generators/php.mjs'))).toBe(true); + // Nothing extra is committed: the merge base comes from the version in the header. + expect(existsSync(join(project, 'generators/.pristine'))).toBe(false); + + // The design ships where an agent auto-loads it, with skill frontmatter. + const design = readFileSync(join(project, '.claude/skills/php-generator/SKILL.md'), 'utf-8'); + expect(design).toContain('name: php-generator'); + expect(design).toContain('edit this skill first'); + // …together with the shared authoring skill (the toolkit and the model). + const authoring = readFileSync( + join(project, '.claude/skills/client-generators/SKILL.md'), + 'utf-8' + ); + expect(authoring).toContain('name: client-generators'); + expect(authoring).toContain('flattenAllOf'); + // And a short pointer next to the code, so the directory explains itself. + const pointer = readFileSync(join(project, 'generators/AGENTS.md'), 'utf-8'); + expect(pointer).toContain('redocly-generators:begin'); + expect(pointer).toContain('.claude/skills/php-generator/SKILL.md'); + + expect(run(project, ['eject-generator', 'php']).status).not.toBe(0); + expect(run(project, ['eject-generator', 'php', '--force']).status).toBe(0); + }, 60_000); + + it('wires itself up: devDependency recorded and the config entry added, once', () => { + const wired = mkdtempSync(join(tmpdir(), 'eject-wire-')); + try { + writeFileSync(join(wired, 'package.json'), JSON.stringify({ name: 'demo' }), 'utf-8'); + writeFileSync( + join(wired, 'redocly.yaml'), + 'extends: []\nclient:\n generators:\n - typescript\n', + 'utf-8' + ); + const eject = run(wired, ['eject-generator', 'go']); + expect(eject.status, eject.stderr).toBe(0); + + const pkg = JSON.parse(readFileSync(join(wired, 'package.json'), 'utf-8')); + // The recorded range is the TOOLKIT's version — the package the ejected file imports. + const toolkitVersion = JSON.parse( + readFileSync(join(repoRoot, 'packages/client-generator/package.json'), 'utf-8') + ).version; + expect(pkg.devDependencies['@redocly/client-generator']).toBe(`^${toolkitVersion}`); + expect(readFileSync(join(wired, 'redocly.yaml'), 'utf-8')).toBe( + 'extends: []\nclient:\n generators:\n - typescript\n - ./generators/go.mjs\n' + ); + + // Re-ejecting must not add the entry twice. + expect(run(wired, ['eject-generator', 'go', '--force']).status).toBe(0); + expect(readFileSync(join(wired, 'redocly.yaml'), 'utf-8').match(/go\.mjs/g)).toHaveLength(1); + + // `--update` re-wires a recorded range the new toolkit no longer satisfies. + const pinned = JSON.parse(readFileSync(join(wired, 'package.json'), 'utf-8')); + pinned.devDependencies['@redocly/client-generator'] = '^0.0.1'; + writeFileSync(join(wired, 'package.json'), JSON.stringify(pinned, null, 2), 'utf-8'); + expect(run(wired, ['eject-generator', 'go', '--update']).status).toBe(0); + expect( + JSON.parse(readFileSync(join(wired, 'package.json'), 'utf-8')).devDependencies[ + '@redocly/client-generator' + ] + ).toBe(`^${toolkitVersion}`); + } finally { + rmSync(wired, { recursive: true, force: true }); + } + }, 60_000); + + it('prints the config snippet when it cannot safely edit the config', () => { + const manual = mkdtempSync(join(tmpdir(), 'eject-manual-')); + try { + const eject = run(manual, ['eject-generator', 'go']); + expect(eject.status, eject.stderr).toBe(0); + const output = eject.stderr + eject.stdout; + expect(output).toContain('generators:'); + expect(output).toContain('./generators/go.mjs'); + // Unwired, the run instruction has to name the copy — nothing else points at it. + expect(output).toContain( + 'Run it: redocly generate-client --output --generator ./generators/go.mjs' + ); + expect(output).toContain('https://redocly.com/docs/cli/commands/eject-generator'); + } finally { + rmSync(manual, { recursive: true, force: true }); + } + }, 60_000); + + it('tells the reader how to run what it just ejected', () => { + const project = makeProject(); + try { + writeFileSync(join(project, 'redocly.yaml'), 'apis:\n main:\n root: openapi.yaml\n'); + const eject = run(project, ['eject-generator', 'python']); + expect(eject.status, eject.stderr).toBe(0); + const output = eject.stderr + eject.stdout; + // Wired into the config, the generator needs no flag — only an api and an output. + expect(output).toContain('Run it: redocly generate-client --output \n'); + expect(output).toContain('Edit generators/python.mjs and run that again'); + expect(output).toContain('https://redocly.com/docs/cli/commands/eject-generator'); + // And that command works as printed. + const generated = run(project, ['generate-client', 'openapi.yaml', '--output', 'client.ts']); + expect(generated.status, generated.stderr).toBe(0); + expect(existsSync(join(project, 'client.py'))).toBe(true); + } finally { + rmSync(project, { recursive: true, force: true }); + } + }, 60_000); + + it('THE headline: an ejected-unmodified generator produces byte-identical output', () => { + const builtin = run(project, [ + 'generate-client', + 'openapi.yaml', + '--output', + 'builtin/client.ts', + '--generator', + 'php', + ]); + expect(builtin.status, builtin.stderr).toBe(0); + const ejected = run(project, [ + 'generate-client', + 'openapi.yaml', + '--output', + 'ejected/client.ts', + '--generator', + './generators/php.mjs', + ]); + expect(ejected.status, ejected.stderr).toBe(0); + expect(ejected.stderr).toContain('takes over the built-in generator'); + expect(readFileSync(join(project, 'ejected/client.php'), 'utf-8')).toBe( + readFileSync(join(project, 'builtin/client.php'), 'utf-8') + ); + }, 60_000); + + it('THE headline holds for a bundled TypeScript generator too', () => { + const eject = run(project, ['eject-generator', 'zod']); + expect(eject.status, eject.stderr).toBe(0); + + const builtin = run(project, [ + 'generate-client', + 'openapi.yaml', + '--output', + 'zod-builtin/client.ts', + '--generator', + 'typescript', + '--generator', + 'zod', + ]); + expect(builtin.status, builtin.stderr).toBe(0); + const ejected = run(project, [ + 'generate-client', + 'openapi.yaml', + '--output', + 'zod-ejected/client.ts', + '--generator', + 'typescript', + '--generator', + './generators/zod.mjs', + ]); + expect(ejected.status, ejected.stderr).toBe(0); + expect(readFileSync(join(project, 'zod-ejected/client.zod.ts'), 'utf-8')).toBe( + readFileSync(join(project, 'zod-builtin/client.zod.ts'), 'utf-8') + ); + }, 60_000); + + it('a framework variant points at the generator it is an argument of; unknown names error', () => { + const variant = run(project, ['eject-generator', 'tanstack-query-vue']); + expect(variant.status).toBe(0); + expect(variant.stderr + variant.stdout).toContain("tanstackQueryGenerator('vue')"); + expect(existsSync(join(project, 'generators/tanstack-query-vue.mjs'))).toBe(false); + expect(run(project, ['eject-generator', 'nowhere']).status).not.toBe(0); + }, 60_000); + + it('--update merges cleanly around local edits and marks real conflicts', () => { + appendFileSync(join(project, 'generators/php.mjs'), '// my local customization\n'); + // The skill is edit-first too — an update must merge around a design note, not drop it. + const skillPath = join(project, '.claude/skills/php-generator/SKILL.md'); + appendFileSync(skillPath, '\n## Our fork\n\nWe keep the legacy auth header.\n'); + const clean = run(project, ['eject-generator', 'php', '--update']); + expect(clean.status, clean.stderr).toBe(0); + expect(readFileSync(join(project, 'generators/php.mjs'), 'utf-8')).toContain( + '// my local customization' + ); + expect(readFileSync(skillPath, 'utf-8')).toContain('We keep the legacy auth header.'); + + // A `.pristine/` copy from an older CLI still works as the base, and says it can go. + const legacy = join(project, 'generators/.pristine'); + mkdirSync(legacy, { recursive: true }); + const ejected = join(project, 'generators/php.mjs'); + const base = readFileSync(ejected, 'utf-8').split('\n'); + const mine = [...base]; + base[0] = '// OLD base line'; + mine[0] = '// USER edited line'; + writeFileSync(join(legacy, 'php.mjs'), base.join('\n'), 'utf-8'); + writeFileSync(ejected, mine.join('\n'), 'utf-8'); + const conflicted = run(project, ['eject-generator', 'php', '--update']); + expect(conflicted.status, conflicted.stderr).toBe(0); + const output = conflicted.stderr + conflicted.stdout; + expect(output).toContain('conflict'); + expect(output).toContain('.pristine'); + expect(readFileSync(ejected, 'utf-8')).toContain('<<<<<<<'); + }, 60_000); +}); + +describe('eject-generator from source (no bundle)', () => { + // The command reads its assets beside the bundle, which only the CLI build produces. + // Running `packages/cli/src` — what `npm run cli` does — used to fail with an ENOENT + // naming a path inside `src`, so contributors could not eject during development. + it('ejects every generator when the CLI runs from src', () => { + const project = makeProject(); + try { + for (const generator of ['python', 'go', 'php', 'typescript', 'cli']) { + const result = spawnSync( + tsxBin, + [join(repoRoot, 'packages/cli/src/index.ts'), 'eject-generator', generator], + { cwd: project, encoding: 'utf-8' } + ); + expect(result.status, `${generator}: ${result.stdout}\n${result.stderr}`).toBe(0); + expect(existsSync(join(project, `generators/${generator}.mjs`))).toBe(true); + } + } finally { + rmSync(project, { recursive: true, force: true }); + } + }, 120_000); +}); diff --git a/tests/e2e/generate-client/envelope.test.ts b/tests/e2e/generate-client/envelope.test.ts index fa51e6a6b3..3021ca0f17 100644 --- a/tests/e2e/generate-client/envelope.test.ts +++ b/tests/e2e/generate-client/envelope.test.ts @@ -42,14 +42,14 @@ describe('generate-client envelope', () => { '', // Options that never mention `envelope` keep the plain body type. 'export async function bodyWithOptions() {', - " const rows = await listCustomers({ headers: { 'X-Trace': '1' } });", + " const rows = await listCustomers({}, { headers: { 'X-Trace': '1' } });", " const viaClientRows = await client.listCustomers({}, { parseAs: 'json' });", ' return rows.map((row) => row.id).concat(viaClientRows.map((row) => row.id));', '}', '', // Flat sugar: no-input ops take `init` as the first argument. 'export async function withEnvelope() {', - ' const { data, headers, response } = await listCustomers({ envelope: true });', + ' const { data, headers, response } = await listCustomers({}, { envelope: true });', ' const total: number = headers.paginationTotal;', ' const flag: boolean | undefined = headers.xFlag;', ' const secure: boolean = headers._3dSecure;', @@ -65,7 +65,7 @@ describe('generate-client envelope', () => { '}', '', 'export async function bodylessResponse() {', - " const { data, headers } = await createCustomer('cus_1', { envelope: true });", + " const { data, headers } = await createCustomer({ path: { id: 'cus_1' } }, { envelope: true });", ' const nothing: void = data;', ' const location: string = headers.location;', ' return { nothing, location };', @@ -73,7 +73,7 @@ describe('generate-client envelope', () => { '', 'export async function widenedEnvelopeOption() {', ' const options = { envelope: true };', - ' const result = await listCustomers(options);', + ' const result = await listCustomers({}, options);', " return 'response' in result ? result.data.length : result.length;", '}', '', diff --git a/tests/e2e/generate-client/error-mode.test.ts b/tests/e2e/generate-client/error-mode.test.ts index e06ffb4bb5..43c2fe5bf9 100644 --- a/tests/e2e/generate-client/error-mode.test.ts +++ b/tests/e2e/generate-client/error-mode.test.ts @@ -38,7 +38,7 @@ describe('generate-client error mode', () => { expect(existsSync(out)).toBe(true); const generated = readFileSync(out, 'utf-8'); - expect(generated).toContain('export const getThing = ('); + expect(generated).toContain('export const { getThing } = client;'); expect(generated).toContain('result: Result;'); expect(generated).toContain('export type GetThingError = ProblemDetails;'); // The mode is baked into the client instance config (configure() cannot flip it). diff --git a/tests/e2e/generate-client/examples.test.ts b/tests/e2e/generate-client/examples.test.ts index 62aaca272e..85cb01c63b 100644 --- a/tests/e2e/generate-client/examples.test.ts +++ b/tests/e2e/generate-client/examples.test.ts @@ -83,3 +83,34 @@ describe('examples generate with the current generator', () => { }, 60_000); } }); + +describe('the ejected example carries the current skills', () => { + // The example commits what `redocly eject-generator` drops, so a browser sees the + // whole story; these pin the committed copies to the shipped assets. + const shippedSkill = (skill: string) => + readFileSync( + join(repoRoot, 'packages/client-generator/eject-assets/skills', skill, 'SKILL.md'), + 'utf-8' + ); + const exampleDir = join(examplesDir, 'ejected-generator'); + + it.each(['client-generators', 'php-generator'])( + '%s/SKILL.md matches the shipped skill', + (skill) => { + const committed = readFileSync( + join(exampleDir, '.claude/skills', skill, 'SKILL.md'), + 'utf-8' + ); + expect(committed, 'stale — re-run `redocly eject-generator` in the example').toBe( + shippedSkill(skill) + ); + } + ); + + it('generators/AGENTS.md points at both skills', () => { + const pointer = readFileSync(join(exampleDir, 'generators/AGENTS.md'), 'utf-8'); + expect(pointer).toContain('redocly-generators:begin'); + expect(pointer).toContain('.claude/skills/client-generators/SKILL.md'); + expect(pointer).toContain('.claude/skills/php-generator/SKILL.md'); + }); +}); diff --git a/tests/e2e/generate-client/examples/README.md b/tests/e2e/generate-client/examples/README.md index cf4237bf65..f2678424a7 100644 --- a/tests/e2e/generate-client/examples/README.md +++ b/tests/e2e/generate-client/examples/README.md @@ -2,30 +2,37 @@ Runnable examples of clients generated by `@redocly/client-generator`. Most are Vite apps that _consume_ a client generated via the `redocly generate-client` CLI (a `redocly.yaml`); `programmatic` _generates_ one with the `generateClient(...)` API. -Nine examples share the cafe spec in [`_shared/cafe.yaml`](./_shared/cafe.yaml); the rest carry their own. +Most share the cafe spec in [`_shared/cafe.yaml`](./_shared/cafe.yaml). +The rest carry their own. The generated client under `src/api/` is gitignored — CI regenerates every client and type-checks the consumer code against it (the `examples` job), and `zero-install-quickstart` keeps its client committed as the canonical browsable copy, drift-checked in `tests/e2e/generate-client/examples.test.ts`. -| Example | How it's generated | Shows | -| ------------------------------------------------------ | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| [fetch-functions](./fetch-functions) | CLI · `sdk`, functions | free functions + `ApiError` | -| [baked-setup](./baked-setup) | CLI · `sdk`, functions | publisher defaults baked into the client via `--setup` (`defineClientSetup`) | -| [zod](./zod) | CLI · `sdk`, `zod` | validating responses with generated zod schemas | -| [tanstack-query](./tanstack-query) | CLI · `sdk`, `tanstack-query` | React `useQuery(Options())` | -| [mock](./mock) | CLI · `sdk`, `mock` | MSW handlers from generated `handlers` | -| [programmatic](./programmatic) | `generateClient(...)` API | generating the client from a Node script | -| [package-runtime](./package-runtime) | CLI · `sdk`, package runtime | `runtime: package` — types + descriptors only; the versioned runtime is imported from `@redocly/client-generator`, fixes via `npm update` | -| [zero-install-quickstart](./zero-install-quickstart) | CLI · `sdk` | the first-touch loop: generate → import → call; one self-contained file, zero runtime dependencies | -| [node-native](./node-native) | CLI · `sdk` | `importExt: ts` — `.ts` import specifiers so plain `node src/main.ts` runs the client via Node's built-in type stripping | -| [configure-and-middleware](./configure-and-middleware) | CLI · `sdk` | `configure({ serverUrl, retry, fetch })`, `use()` targeting `ctx.operation` (literal unions), body mutation, auth setter, `ApiError.body` | -| [multi-instance](./multi-instance) | CLI · `sdk`, package runtime | per-tenant instances via `createClient(OPERATIONS)` — works in both runtimes; this example uses `runtime: package` | -| [sse-streaming](./sse-streaming) | CLI · `sdk` | typed `for await` over SSE, auto-reconnect via `Last-Event-ID` (`reconnectDelay`/`reconnect: false`), clean abort | -| [vendored-edge](./vendored-edge) | CLI · `sdk` | the generated file copied into a no-npm edge worker (`export default { fetch }`); `typescript` is the only dev tool | -| [pagination](./pagination) | CLI · `sdk` | auto-pagination from a `client.pagination` convention: `for await` over `.items()`/`.pages()` next to the unchanged one-shot call | -| [custom-pagination](./custom-pagination) | CLI · `sdk` | hand-written paging over the typed client for shapes the built-in styles don't cover (body cursors) | -| [custom-generator](./custom-generator) | CLI · `sdk` + custom generator | a local `generators` plugin emitting a `: 'METHOD /path'` route map next to the sdk | -| [ast-toolkit-generator](./ast-toolkit-generator) | CLI · `sdk` + custom generator | a plugin emitting real TypeScript AST via `@redocly/client-generator/generate` (`schemaToTypeNode`, `printStatements`) — a typed response-shape map | -| [nested-facade](./nested-facade) | CLI · `sdk` + custom generator | `api..` facade derived from the spec's tags by a plugin — regenerates with the spec | +| Example | How it's generated | Shows | +| ---------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| [fetch-functions](./fetch-functions) | CLI · `typescript`, functions | free functions + `ApiError` | +| [baked-setup](./baked-setup) | CLI · `typescript`, functions | publisher defaults baked into the client via `--setup` (`defineClientSetup`) | +| [zod](./zod) | CLI · `typescript`, `zod` | validating responses with generated zod schemas | +| [tanstack-query](./tanstack-query) | CLI · `typescript`, `tanstack-query` | React `useQuery(Options())` | +| [mock](./mock) | CLI · `typescript`, `mock` | MSW handlers from generated `handlers` | +| [programmatic](./programmatic) | `generateClient(...)` API | generating the client from a Node script | +| [package-runtime](./package-runtime) | CLI · `typescript`, package runtime | `runtime: package` — types + descriptors only; the versioned runtime is imported from `@redocly/client-generator`, fixes via `npm update` | +| [zero-install-quickstart](./zero-install-quickstart) | CLI · `typescript` | the first-touch loop: generate → import → call; one self-contained file, zero runtime dependencies | +| [node-native](./node-native) | CLI · `typescript` | `importExt: ts` — `.ts` import specifiers so plain `node src/main.ts` runs the client via Node's built-in type stripping | +| [configure-and-middleware](./configure-and-middleware) | CLI · `typescript` | `configure({ serverUrl, retry, fetch })`, `use()` targeting `ctx.operation` (literal unions), body mutation, auth setter, `ApiError.body` | +| [multi-instance](./multi-instance) | CLI · `typescript`, package runtime | per-tenant instances via `createClient(OPERATIONS)` — works in both runtimes; this example uses `runtime: package` | +| [sse-streaming](./sse-streaming) | CLI · `typescript` | typed `for await` over SSE, auto-reconnect via `Last-Event-ID` (`reconnectDelay`/`reconnect: false`), clean abort | +| [vendored-edge](./vendored-edge) | CLI · `typescript` | the generated file copied into a no-npm edge worker (`export default { fetch }`); `typescript` is the only dev tool | +| [pagination](./pagination) | CLI · `typescript` | auto-pagination from a `client.pagination` convention: `for await` over `.items()`/`.pages()` next to the unchanged one-shot call | +| [custom-pagination](./custom-pagination) | CLI · `typescript` | hand-written paging over the typed client for shapes the built-in styles don't cover (body cursors) | +| [custom-generator](./custom-generator) | CLI · `typescript` + custom generator | a local `generators` plugin emitting a `: 'METHOD /path'` route map next to the client | +| [typescript-types-generator](./typescript-types-generator) | CLI · `typescript` + custom generator | a plugin rendering real TypeScript types via `@redocly/client-generator/generate` (`tsType`) — a typed response-shape map | +| [valibot-generator](./valibot-generator) | CLI · `typescript` + custom generator | a ~60-line custom generator emitting Valibot schemas — the recipe for a validation library the built-ins do not cover | +| [nested-facade](./nested-facade) | CLI · `typescript` + custom generator | `api..` facade derived from the spec's tags by a plugin — regenerates with the spec | +| [cli](./cli) | CLI · `typescript`, `zod`, `cli` · `docs` | a bin-ready command-line interface over the client: typed flags, `--json` bodies, `--dry-run`, a documented exit-code contract | +| [python-sdk](./python-sdk) | CLI · `python` · `docs` | a full Python SDK (httpx): typed dataclasses, sync/async clients, pagination iterators | +| [go-sdk](./go-sdk) | CLI · `go` | a full Go SDK (stdlib-only): typed structs, `(T, error)` methods, `context.Context` | +| [php-sdk](./php-sdk) | CLI · `php` | a full PHP SDK (curl extension): promoted-constructor classes, native enums, named-argument methods | +| [ejected-generator](./ejected-generator) | CLI · ejected `php` | `eject-generator php` vendored + customized: the path entry shadows the built-in name; regeneration keeps the customization | ## Run one diff --git a/tests/e2e/generate-client/examples/baked-setup/redocly.yaml b/tests/e2e/generate-client/examples/baked-setup/redocly.yaml index 91dd1bc792..d9e7777530 100644 --- a/tests/e2e/generate-client/examples/baked-setup/redocly.yaml +++ b/tests/e2e/generate-client/examples/baked-setup/redocly.yaml @@ -7,5 +7,5 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript setup: ./client-setup.ts diff --git a/tests/e2e/generate-client/examples/cli/.gitignore b/tests/e2e/generate-client/examples/cli/.gitignore new file mode 100644 index 0000000000..612acc5cae --- /dev/null +++ b/tests/e2e/generate-client/examples/cli/.gitignore @@ -0,0 +1,3 @@ +node_modules +src/api/ +package-lock.json diff --git a/tests/e2e/generate-client/examples/cli/README.md b/tests/e2e/generate-client/examples/cli/README.md new file mode 100644 index 0000000000..309cb8112c --- /dev/null +++ b/tests/e2e/generate-client/examples/cli/README.md @@ -0,0 +1,28 @@ +# cli + +The `cli` generator emits `src/api/client.cli.ts` — a bin-ready, zero-dependency command-line interface over the generated client. +Path params are positional, query params become typed `--kebab-name` flags, and JSON bodies arrive via `--json ''`, `--json @file.json`, or `--json @-` (stdin). +With `zod` co-selected (as here), requests are validated before they are sent — an invalid body exits with code 3 and never reaches the network. + +Commands are grouped by tag and addressed by the tag's shell-typable slug — `Products` is typed `products` — and a unique operationId also works on its own. + +Generate the client, then drive the API from the shell: + +```sh +npm run generate + +npx tsx src/api/client.cli.ts --help +npx tsx src/api/client.cli.ts listMenuItems --limit 3 +npx tsx src/api/client.cli.ts createOrder --json @order.json --dry-run +npx tsx src/api/client.cli.ts products listMenuItems --limit 3 # the tag group, for an ambiguous name +npx tsx src/api/client.cli.ts schema createOrder +``` + +`--dry-run` prints the prepared request (credentials redacted) without sending it. +Credentials come from environment variables derived from the file stem: `CLIENT_TOKEN` for bearer auth here, or pass `--token`. +Exit codes are a documented contract (0 ok, 1 API error, 2 auth, 3 validation, 4 usage), and errors print one JSON object to stderr so stdout stays clean for piping. +To ship a real bin, compile with `tsc` and point `package.json`'s `bin` at the compiled CLI module (`dist/api/client.cli.js`), not at the client beside it. + +`client.docs: true` (the `--docs` flag) is set here, so the CLI also writes its own reference next to itself: `src/api/client.cli.md` — usage, global flags, credential variables, exit codes, and every command with its arguments and flags. +It renders from the same command table the CLI dispatches on, so the page cannot drift from the tool; regenerate and the docs follow. +The page belongs to the `cli` generator, so `redocly eject-generator cli` hands over the layout with the generator — the renderer is the template. diff --git a/tests/e2e/generate-client/examples/cli/order.json b/tests/e2e/generate-client/examples/cli/order.json new file mode 100644 index 0000000000..c96b555278 --- /dev/null +++ b/tests/e2e/generate-client/examples/cli/order.json @@ -0,0 +1,4 @@ +{ + "customerName": "Ada Lovelace", + "orderItems": [{ "menuItemId": "prd_01h1s5z6vf2mm1mz3hevnn9va7", "quantity": 2 }] +} diff --git a/tests/e2e/generate-client/examples/cli/package.json b/tests/e2e/generate-client/examples/cli/package.json new file mode 100644 index 0000000000..bf625bcda8 --- /dev/null +++ b/tests/e2e/generate-client/examples/cli/package.json @@ -0,0 +1,15 @@ +{ + "name": "@redocly-examples/cli", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "generate": "redocly generate-client" + }, + "devDependencies": { + "@redocly/cli": "latest", + "tsx": "^4.19.0", + "typescript": "^5.5.0", + "zod": "^4.0.0" + } +} diff --git a/tests/e2e/generate-client/examples/cli/redocly.yaml b/tests/e2e/generate-client/examples/cli/redocly.yaml new file mode 100644 index 0000000000..07a7bd0201 --- /dev/null +++ b/tests/e2e/generate-client/examples/cli/redocly.yaml @@ -0,0 +1,11 @@ +# redocly.yaml — drives `redocly generate-client` for this example. +apis: + cli: + root: ../_shared/cafe.yaml + clientOutput: ./src/api/client.ts + client: + generators: + - typescript + - zod + - cli + docs: true diff --git a/tests/e2e/generate-client/examples/cli/tsconfig.json b/tests/e2e/generate-client/examples/cli/tsconfig.json new file mode 100644 index 0000000000..e1af1a1d9d --- /dev/null +++ b/tests/e2e/generate-client/examples/cli/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/tests/e2e/generate-client/examples/configure-and-middleware/README.md b/tests/e2e/generate-client/examples/configure-and-middleware/README.md index 62b744d93e..3c7472ed3e 100644 --- a/tests/e2e/generate-client/examples/configure-and-middleware/README.md +++ b/tests/e2e/generate-client/examples/configure-and-middleware/README.md @@ -9,7 +9,7 @@ from the hand-written `src/main.ts`, so it survives regeneration - `use()` middleware targeting `ctx.operation.id` / `ctx.operation.tags` (typed literal unions — typos fail the build), mutating the request body (`ctx.body` edits are sent), and observing each attempt's raw `Response`. -- The generated `setApiKey()` auth setter. +- Setting a credential for one scheme with `client.auth.apiKey()`. - A per-call header via the trailing `RequestOptions` argument. - `ApiError` handling with the spec's problem document on `error.body`. diff --git a/tests/e2e/generate-client/examples/configure-and-middleware/redocly.yaml b/tests/e2e/generate-client/examples/configure-and-middleware/redocly.yaml index e8e7e00d44..2962279525 100644 --- a/tests/e2e/generate-client/examples/configure-and-middleware/redocly.yaml +++ b/tests/e2e/generate-client/examples/configure-and-middleware/redocly.yaml @@ -5,4 +5,4 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript diff --git a/tests/e2e/generate-client/examples/configure-and-middleware/src/main.ts b/tests/e2e/generate-client/examples/configure-and-middleware/src/main.ts index b33ca4d3fa..01ce54104f 100644 --- a/tests/e2e/generate-client/examples/configure-and-middleware/src/main.ts +++ b/tests/e2e/generate-client/examples/configure-and-middleware/src/main.ts @@ -4,17 +4,17 @@ // `Retry-After` honored; per-call override via `init.retry`). // * `use()`: middleware that targets `ctx.operation.id` — a LITERAL UNION of this // spec's operation ids, so a typo fails the build instead of silently never matching. -// * `setApiKey()`: per-scheme auth sugar; injected only on operations whose +// * `client.auth.apiKey()`: a credential per scheme; injected only on operations whose // `security` names the scheme. // * `ApiError`: a non-2xx response throws, carrying the decoded problem document // on `error.body`. import { ApiError, + client, configure, createPayment, getPayment, listPayments, - setApiKey, use, type ProblemDetails, } from './api/client.js'; @@ -61,7 +61,7 @@ configure({ // Auth sugar generated from the spec's `ApiKeyAuth` scheme: every operation whose // `security` requires it gets an `X-Api-Key` header — nothing to wire by hand. -setApiKey('demo-key-123'); +client.auth.apiKey('ApiKey', 'demo-key-123'); use({ onRequest: (ctx) => { @@ -89,9 +89,11 @@ use({ async function main() { // A header for this one call only goes in the trailing RequestOptions argument. const payments = await listPayments({}, { headers: { 'X-Request-Id': '42' } }); // 503 first, then retried to 200 - const payment = await createPayment({ amount: 4200, currency: 'EUR', reference: 'INV-17' }); + const payment = await createPayment({ + body: { amount: 4200, currency: 'EUR', reference: 'INV-17' }, + }); try { - await getPayment('pay_missing'); + await getPayment({ path: { paymentId: 'pay_missing' } }); } catch (error) { if (error instanceof ApiError) { // `error.body` is the decoded response body; per the spec's 4xx contract diff --git a/tests/e2e/generate-client/examples/custom-generator/README.md b/tests/e2e/generate-client/examples/custom-generator/README.md index 651b984c34..ef5eede900 100644 --- a/tests/e2e/generate-client/examples/custom-generator/README.md +++ b/tests/e2e/generate-client/examples/custom-generator/README.md @@ -1,11 +1,11 @@ # Custom generator (plugin) example Shows the **experimental** custom-generator API: a `generators` entry that is a path to a local -generator runs alongside the built-in `sdk`, reading the same OpenAPI-derived IR. +generator runs alongside the built-in `typescript`, reading the same OpenAPI-derived IR. - [`route-map-generator.mjs`](./route-map-generator.mjs) — the custom generator. Walks the IR's operations and emits `src/api/client.routes.ts`: `: 'METHOD /path'`. -- [`redocly.yaml`](./redocly.yaml) — `generators: [sdk, ./route-map-generator.mjs]`. +- [`redocly.yaml`](./redocly.yaml) — `generators: [typescript, ./route-map-generator.mjs]`. - [`src/main.ts`](./src/main.ts) — imports both the client and the generated `routes` map. Regenerate from the repo root with `npm run examples:regen -w @redocly/client-generator`; type-check diff --git a/tests/e2e/generate-client/examples/custom-generator/redocly.yaml b/tests/e2e/generate-client/examples/custom-generator/redocly.yaml index 84da4e37a8..4b92c69311 100644 --- a/tests/e2e/generate-client/examples/custom-generator/redocly.yaml +++ b/tests/e2e/generate-client/examples/custom-generator/redocly.yaml @@ -7,5 +7,5 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript - ./route-map-generator.mjs diff --git a/tests/e2e/generate-client/examples/custom-generator/route-map-generator.mjs b/tests/e2e/generate-client/examples/custom-generator/route-map-generator.mjs index f8a9133b66..ae5109a626 100644 --- a/tests/e2e/generate-client/examples/custom-generator/route-map-generator.mjs +++ b/tests/e2e/generate-client/examples/custom-generator/route-map-generator.mjs @@ -1,58 +1,28 @@ // A custom generator (the experimental plugin API). Loaded by the `generators:` list in -// redocly.yaml as a path specifier, it runs alongside the built-in `sdk` and emits a +// redocly.yaml as a path specifier, it runs alongside the built-in `typescript` and emits a // `.routes.ts` map of every operation — `: 'METHOD /path'`. // -// The output is built as a real TypeScript AST with the `@redocly/client-generator/generate` -// toolkit — the same `ts.factory` + printer the built-in generators use — so quoting and -// formatting come out right for free. Plain ESM so the CLI imports it under bare `node`. -// Authored in TypeScript you would write: +// The output is a source-text template — the same authoring model every built-in +// generator uses. Plain ESM so the CLI imports it under bare `node`. Authored in +// TypeScript you would write: // // import { defineGenerator } from '@redocly/client-generator'; -// import { printStatements, ts } from '@redocly/client-generator/generate'; -// export default defineGenerator({ name: 'route-map', requires: ['sdk'], run({ model, outputPath }) { … } }); +// export default defineGenerator({ name: 'route-map', requires: ['typescript'], run({ model, outputPath }) { … } }); // // `defineGenerator` is just an identity helper for types, so a plain object works too: -import { printStatements, ts } from '@redocly/client-generator/generate'; - -const { factory } = ts; - export default { name: 'route-map', - requires: ['sdk'], + requires: ['typescript'], run({ model, outputPath }) { const entries = model.services .flatMap((service) => service.operations) - .map((op) => - factory.createPropertyAssignment( - op.name, - factory.createStringLiteral(`${op.method.toUpperCase()} ${op.path}`, true) - ) - ); - // export const routes = { … } as const; - const routes = factory.createVariableStatement( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - factory.createVariableDeclarationList( - [ - factory.createVariableDeclaration( - 'routes', - undefined, - undefined, - factory.createAsExpression( - factory.createObjectLiteralExpression(entries, true), - factory.createTypeReferenceNode('const') - ) - ), - ], - ts.NodeFlags.Const - ) - ); + .map((op) => ` ${op.name}: '${op.method.toUpperCase()} ${op.path}',`); return [ { path: outputPath.replace(/\.ts$/, '.routes.ts'), content: '// Generated by the route-map custom generator. Do not edit by hand.\n' + - printStatements([routes]) + - '\n', + `export const routes = {\n${entries.join('\n')}\n} as const;\n`, }, ]; }, diff --git a/tests/e2e/generate-client/examples/custom-generator/src/main.ts b/tests/e2e/generate-client/examples/custom-generator/src/main.ts index e8e1449f2e..f9658e4eff 100644 --- a/tests/e2e/generate-client/examples/custom-generator/src/main.ts +++ b/tests/e2e/generate-client/examples/custom-generator/src/main.ts @@ -1,4 +1,4 @@ -// Consumes both the built-in sdk client and the custom generator's output (`routes`), +// Consumes both the built-in typescript client and the custom generator's output (`routes`), // proving the plugin's file is generated alongside the client and type-checks. import { configure, listMenuItems } from './api/client.js'; import { routes } from './api/client.routes.js'; diff --git a/tests/e2e/generate-client/examples/custom-pagination/redocly.yaml b/tests/e2e/generate-client/examples/custom-pagination/redocly.yaml index f78904a9c5..257bbe5e4e 100644 --- a/tests/e2e/generate-client/examples/custom-pagination/redocly.yaml +++ b/tests/e2e/generate-client/examples/custom-pagination/redocly.yaml @@ -10,4 +10,4 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript diff --git a/tests/e2e/generate-client/examples/custom-pagination/src/main.ts b/tests/e2e/generate-client/examples/custom-pagination/src/main.ts index 3917c324c6..c18c9c87d1 100644 --- a/tests/e2e/generate-client/examples/custom-pagination/src/main.ts +++ b/tests/e2e/generate-client/examples/custom-pagination/src/main.ts @@ -38,6 +38,8 @@ async function* paginate( } } -for await (const order of paginate((cursor) => searchOrders({ status: 'ready', cursor }))) { +for await (const order of paginate((cursor) => + searchOrders({ body: { status: 'ready', cursor } }) +)) { console.log(`search hit ${order.id}: ${order.drink}`); // `order` is `Order` — typed end to end } diff --git a/tests/e2e/generate-client/examples/ejected-generator/.claude/skills/client-generators/SKILL.md b/tests/e2e/generate-client/examples/ejected-generator/.claude/skills/client-generators/SKILL.md new file mode 100644 index 0000000000..3a0250145a --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/.claude/skills/client-generators/SKILL.md @@ -0,0 +1,136 @@ +--- +name: client-generators +description: Write or change a Redocly client generator — the API model, the language-neutral helper toolkit, and the edit → regenerate → diff loop. +--- + +# Writing custom client generators + +A generator is a plain module: `(input) => GeneratedFile[]`. It receives the +language-agnostic API model and returns files — in ANY output language. It runs +in the same pass as the built-ins; select it by path in `redocly.yaml`: + +```yaml +client: + generators: [typescript, ./generators/my-generator.mjs] +``` + +## The contract + +```js +/** @type {import('@redocly/client-generator').CustomGenerator} */ +export default { + name: 'my-generator', + run({ model, outputPath, outputMode, emit }) { + return [{ path: outputPath.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. + sample(operation, { model, emit }) { + return { lang: 'python', source: '…' }; + }, + // 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: '…' }]; + }, +}; +``` + +## Declaring options + +A generator that needs configuration declares it as a schema; `run` then receives +`options` already validated, with defaults applied: + +```js +export default { + name: 'permissions-matrix', + options: { + type: 'object', + properties: { groupBy: { enum: ['tag', 'path'], default: 'tag' } }, + additionalProperties: false, + }, + run({ model, outputPath, options }) { + return [ + { path: outputPath.replace(/\.ts$/, '.permissions.md'), content: render(options.groupBy) }, + ]; + }, +}; +``` + +Users set them per generator name: + +```yaml +client: + generators: [typescript, ./generators/permissions-matrix.mjs] + options: + permissions-matrix: + groupBy: path +``` + +The supported subset is a top-level `type: 'object'` with `properties`, `required`, and +`additionalProperties`; each property is a scalar (`string`/`number`/`boolean`), an +`enum`, or an array of scalars, and may carry a `default` and a `description`. Don't +validate options inside `run` — an unknown key, a wrong type, a value outside an `enum`, +or a missing `required` key already fails generation before `run` is called. + +Rules: output is deterministic (same description → same bytes); never add +dependencies to the generated client; **never hand-edit generated output** — +edit this generator and regenerate. Emitted file paths must stay inside the +`--output` directory (subdirectories are fine) — escapes are rejected. +Optionally declare `requiresGenerator` — the `@redocly/client-generator` version +range you wrote this against (`'^1.2.0'`, `'~1.2.0'`, `'>=1.2.0'`, or an exact +version). A CLI outside the range then fails with the fix path instead of feeding +your generator an unexpected model shape. Ejected generators carry it +automatically; hand-written ones without it are taken as current. + +## The model (IR) + +`model.services[].operations[]` — each operation carries `name`, `specName`, +`method`, `path`, `tags`, `pathParams`/`queryParams`/`headerParams`/`cookieParams`, +`requestBody`, `successResponses`/`errorResponses` (each with a `schema`), and +`security`. `model.schemas` holds the named schemas. Every schema is a +discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, +`literal`, `enum`, `union` (optionally with a discriminator), `intersection` +(allOf), `null`, `unknown`, `omit`. + +## 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). | + +Worked example: the built-in `python` generator +(`packages/client-generator/src/generators/python/index.ts` in the Redocly CLI repo) is +authored with exactly this toolkit and nothing else — models via `flattenAllOf`/ +`enumValues`/`discriminatorCases`, all code through `Printer`, every name through +`identifierFor(..., RESERVED_WORDS.python)`. + +A generator that emits TypeScript may additionally use the source-text renderers from +`@redocly/client-generator/generate` — `tsType` (schema → type), `tsJsdoc`, `codeLiteral`, +`operationSignature`, `pascalCase`, `safeIdent`. There is no AST toolkit and no +`typescript` dependency: every generator prints text through `Printer`. + +## The loop + +1. Edit the generator. +2. Run `redocly generate-client`. +3. Inspect `git diff` of the generated output. +4. Repeat. Generated files are never hand-edited. + +If you had to work around a **missing helper** or a wrong default, tell the user +and suggest filing an issue at https://github.com/Redocly/redocly-cli/issues — +include the generator snippet and the helper you expected to exist. diff --git a/tests/e2e/generate-client/examples/ejected-generator/.claude/skills/php-generator/SKILL.md b/tests/e2e/generate-client/examples/ejected-generator/.claude/skills/php-generator/SKILL.md new file mode 100644 index 0000000000..acfed7323e --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/.claude/skills/php-generator/SKILL.md @@ -0,0 +1,110 @@ +--- +name: php-generator +description: Design of the ejected Redocly `php` client generator. Read it, and update it, before changing generators/php.mjs. +--- + +# The `php` generator — its skill + +This file is the DESIGN of your ejected `php` generator (`generators/php.mjs`): +**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. + +## What it emits + +One self-contained `.php`: promoted-constructor model classes, a `Client` with one +typed method per operation, and the embedded runtime. PHP ≥ 8.1, HTTP over the curl +extension — zero Composer dependencies. The namespace derives from the API title +(`identifierFor(title, pascal)` — e.g. `CafeOrders`). + +## Design decisions that must hold + +- **Models are `final class`es** with constructor property promotion, required parameters + first, optionals nullable `= null`. Hydration is compile-time generated per class: + `fromArray(array $data): self` and `toArray(): array` (wire names inline; nulls + skipped on serialize) — no reflection. `omit` schemas hydrate/serialize through their + base class. A property or response typed as a DISCRIMINATED union hydrates through the + union's `unmarshalX` dispatcher, so consumers can narrow with `instanceof`; + undiscriminated unions stay raw arrays. +- The `Client` class is NOT `final` — PHP test suites mock concrete classes + (`createMock(Client::class)`), and `final` would force a wrapper interface on every + consumer. Model classes stay `final`. +- **Every parameter is its own argument, so their names share one namespace** with the + arguments the method declares itself (`$body`, `$headers`, `$idempotencyKey`). Build them with + `uniqueIdentifiers(..., { taken: … })`: OpenAPI lets one operation use a name in two + locations (`id` in the path AND in the query), and PHP rejects a redefined parameter outright. The + wire name is untouched, so the request is unchanged. +- **Naming:** classes PascalCase, properties/methods camelCase via + `identifierFor(..., RESERVED_WORDS.php)`; reserved words get a trailing underscore. +- **Enums** are native backed enums (string/int); other scalars stay aliases. + **Discriminated unions** are `match`-based `unmarshalX(array $data)` dispatchers; + **allOf** is flattened. +- **Unions keep their types where PHP 8.1 can express them.** A union of scalars, enums, + classes, or arrays becomes a native union type (`int|string`, `PromotionType|array`) + rather than collapsing to `mixed` — rich list filters are the common case and losing + their types loses the point of a typed SDK. It falls back to `mixed` only when a member + has no PHP type of its own (an inline object, an intersection, `unknown`), because + `mixed` cannot appear inside a union. Nullability is expressed as `|null` in a union + (PHP forbids mixing `?` with `|`) and `?T` for a single type. +- **Errors:** exceptions ARE the error mode (`ApiError`/`TimeoutError` extend + `\RuntimeException`); `errorMode` does not change the output (the generator declares + `errorModes: ['throw']`, so `result` fails fast). +- **Dates:** `dateType: Date` types `format: date`/`date-time` as + `\DateTimeImmutable`; hydration is `new \DateTimeImmutable(...)` and serialization + formats with `\DateTimeInterface::ATOM` (date-time) or `'Y-m-d'` (date), including + for query parameters. +- **Method arguments:** required path params positional, JSON body next, optional query + params as nullable NAMED arguments, then `?array $headers`, and `?string +$idempotencyKey` on mutating methods. +- **Non-JSON success bodies** (PDFs, images, octet streams) return the raw body as + `string` — a binary download must never degrade to `void`. +- **PHPDoc carries what the signature cannot.** PHP's `array` and `\Generator` erase their + element type, so a docblock states it: `@return Customer[]` for collection returns and + `@return \Generator` on `Pages()`/`Items()`. Static analysis and + readers go by these; a hydrated return with no annotation looks untyped. +- **Response headers:** an operation that DECLARES success-response headers gains a + `WithHeaders()` variant returning an `Envelope` (`data`, `headers` — coerced to + int/bool/string with camelCase keys, absent/unparsable values omitted — and `status`). + Operations without declared headers get no variant, and the base method stays + body-only (PHP cannot vary a return type on a flag). +- **Servers:** when the description declares servers, a `Servers` class is emitted with + one static method per server; server VARIABLES become named string arguments defaulting + to the spec's defaults (`Servers::production(organizationId: 'org_x')`), so templated + base URLs need no manual string building. The client's baked default stays `servers[0]` + with variable defaults substituted. +- **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 + at prepare time. `curl_close` is never called (deprecated since PHP 8.5, no-op since 8.0). +- Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. + +## Migrating from a service-based SDK + +- Per-resource services (`$client->customers()->get($id)`) map to flat methods named + after operationIds (`$client->getCustomer($id)`); optional query params keep their + named-argument style (`filter:`, `sort:`, `limit:`). +- Collection wrappers exposing pagination RESPONSE HEADERS (`getTotalItems()`, + `getLimit()`) map to the `WithHeaders()` envelope + (`->headers['paginationTotal']`); plain iteration maps to `Items()` / + `Pages()` generators. +- Dedicated validation-exception classes exposing field errors map to + `catch (ApiError $e)` + `$e->status === 422` + the decoded `$e->body`. +- Session/bearer token flows map to `auth: ['bearer' => $tokenProvider]` with a + callable — resolved per request, so refresh needs no client rebuild. + +- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes + `.php.md`: the security schemes, then one section per operation with its parameters, + body, response type, and behavior notes. The call snippets come from this generator's own + `sample` hook, so the page can only show the syntax of the SDK beside it, and the layout + comes from `renderReferencePage` in the authoring toolkit — reachable from an ejected copy + through `@redocly/client-generator`. Pagination on the page is decided by + `paginationRuleFor`, the same helper this generator resolves pagination with. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/php.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator php --update`. diff --git a/tests/e2e/generate-client/examples/ejected-generator/.gitignore b/tests/e2e/generate-client/examples/ejected-generator/.gitignore new file mode 100644 index 0000000000..612acc5cae --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/.gitignore @@ -0,0 +1,3 @@ +node_modules +src/api/ +package-lock.json diff --git a/tests/e2e/generate-client/examples/ejected-generator/README.md b/tests/e2e/generate-client/examples/ejected-generator/README.md new file mode 100644 index 0000000000..2d52856214 --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/README.md @@ -0,0 +1,21 @@ +# ejected-generator + +The `shadcn` story for generators: `redocly eject-generator php` vendored the built-in PHP generator into `generators/php.mjs`, and this repo customized it. +Search the file for `CUSTOMIZATION` to see the one-line change (a platform banner in the generated header). +The generated client stays machine-owned: regenerate any time while preserving customization. +The customization lives in the generator, not in its output. + +```sh +npm run generate +head src/api/client.php # the customized banner is in the generated header +npm run update-generator # merge a newer generator version into the customized copy +``` + +`.claude/skills/php-generator/SKILL.md` is the generator's design and `.claude/skills/client-generators/SKILL.md` is the authoring toolkit — both committed here exactly as the command drops them. +Your coding agent loads them on its own: describe the change you want, and it edits the design first, then the generator. +`generators/AGENTS.md` is the short pointer the command leaves beside the code. +`npm run update-generator` three-way-merges a newer generator version into this customized copy — clean hunks apply silently, real conflicts get standard `<<<<<<<` markers. +The merge base is the version recorded in the file's own header, so there is nothing extra to commit or keep in sync. +This example started from `redocly eject-generator php`. +Run this command in your own repo to begin. +The ejected file imports the authoring toolkit and the embedded runtime from `@redocly/client-generator`, so runtime fixes still arrive with plain `npm update` — no merge needed. diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md b/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md new file mode 100644 index 0000000000..f26d20614a --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md @@ -0,0 +1,12 @@ + + +# Ejected client generators + +These files are Redocly client generators you own; `redocly generate-client` runs them. +Their design and the authoring toolkit are agent skills — edit the skill first, then make +the code match, and never hand-edit generated client output: + +- `.claude/skills/client-generators/SKILL.md` — the API model, the helpers, the loop. +- `.claude/skills/php-generator/SKILL.md` — the `php` generator's design. + + diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/php.mjs b/tests/e2e/generate-client/examples/ejected-generator/generators/php.mjs new file mode 100644 index 0000000000..608f9abdec --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/php.mjs @@ -0,0 +1,578 @@ +// Ejected from @redocly/client-generator@0.2.0 — the built-in "php" generator. +// This file is yours: edit freely; the generated client stays machine-owned and is +// rebuilt by `redocly generate-client`. Newer generator versions merge in with +// `redocly eject-generator php --update`. +// The built-in `php` generator — the third non-TypeScript library entry, authored +// with the language-neutral toolkit only (same dogfooding invariant as python/go, +// pinned by the guard test). Output is a single PHP >= 8.1 file over the curl +// extension: promoted-constructor classes with fromArray/toArray hydration, native +// backed enums, match-based discriminator dispatchers, and a Client over the +// embedded runtime. Exceptions are the error mode (`errorMode` does not apply). +import { Printer, docText, discriminatorCases, enumValues, flattenAllOf, identifierFor, isNullable, paginationRuleFor, RESERVED_WORDS, schemaAtPointer, unwrapNullable, } from '@redocly/client-generator'; +import { PHP_RUNTIME_SOURCE } from '@redocly/client-generator/runtime-sources'; +const PHP = RESERVED_WORDS.php; +function className(name) { + return identifierFor(name, { style: 'pascal', reserved: PHP }); +} +function propertyName(name) { + return identifierFor(name, { style: 'camel', reserved: PHP }); +} +/** `'…'` with backslashes and quotes escaped — safe for any spec-supplied text. */ +function phpString(value) { + return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; +} +/** Follow ref chains through the named schemas (cycle-guarded). */ +function deref(schema, model) { + 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, model) { + 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, model) { + if (isNullable(schema)) { + const inner = phpType(unwrapNullable(schema), model); + return inner === 'mixed' || inner.startsWith('?') ? inner : `?${inner}`; + } + switch (schema.kind) { + case 'scalar': + 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); + } + 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': + case 'null': + case 'object': + case 'intersection': + case 'unknown': + return 'mixed'; + } +} +/** Wire value → typed value expression, or undefined when the raw value is already right. */ +function hydration(schema, expr, model) { + const bare = unwrapNullable(schema); + if (bare.kind === 'omit') + return hydration({ kind: 'ref', name: bare.base }, expr, model); + 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})`; + const target = deref(bare, model); + return target === undefined ? undefined : hydration(target, expr, model); + } + if (bare.kind === 'array') { + const item = hydration(bare.items, '$item', model); + if (item === undefined) + return undefined; + return `array_map(static fn ($item) => ${item}, ${expr})`; + } + if (bare.kind === 'record') { + const item = hydration(bare.value, '$item', model); + 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, expr, model) { + const bare = unwrapNullable(schema); + if (bare.kind === 'omit') + return serialization({ kind: 'ref', name: bare.base }, expr, model); + if (bare.kind === 'ref') { + const kind = classify(bare.name, model); + if (kind === 'class') + return `${expr}->toArray()`; + if (kind === 'enum') + return `${expr}->value`; + const target = deref(bare, model); + return target === undefined ? undefined : serialization(target, expr, model); + } + if (bare.kind === 'array' || bare.kind === 'record') { + const inner = bare.kind === 'array' ? bare.items : bare.value; + const item = serialization(inner, '$item', model); + if (item === undefined) + return undefined; + return `array_map(static fn ($item) => ${item}, ${expr})`; + } + return undefined; +} +function writeDocComment(writer, name, description) { + const lines = docText(description); + if (lines.length === 0) + return; + writer.line(`/** ${name} — ${lines.join(' ')} */`); +} +function writeClass(writer, name, properties, model, description) { + // PHP requires defaulted parameters after required ones. + const ordered = [ + ...properties.filter((property) => property.required), + ...properties.filter((property) => !property.required), + ]; + writeDocComment(writer, className(name), description); + writer.block(`final class ${className(name)}`, () => { }, ''); + writer.block('{', () => { + writer.block('public function __construct(', () => { + for (const property of ordered) { + const type = phpType(property.schema, model); + if (property.required) { + writer.line(`public ${type} ${'$'}${propertyName(property.name)},`); + } + else { + const nullable = type === 'mixed' || type.startsWith('?') ? type : `?${type}`; + writer.line(`public ${nullable} ${'$'}${propertyName(property.name)} = null,`); + } + } + }, ') {'); + writer.line('}'); + writer.blank(); + writer.block('public static function fromArray(array $data): self', () => { }, ''); + writer.block('{', () => { + writer.block('return new self(', () => { + for (const property of ordered) { + const raw = `$data[${phpString(property.name)}]`; + const typed = hydration(property.schema, raw, model); + const php = propertyName(property.name); + if (property.required) { + writer.line(`${php}: ${typed ?? raw},`); + } + else if (typed === undefined) { + writer.line(`${php}: ${raw} ?? null,`); + } + else { + writer.line(`${php}: isset(${raw}) ? ${typed} : null,`); + } + } + }, ');'); + }, '}'); + writer.blank(); + writer.block('public function toArray(): array', () => { }, ''); + writer.block('{', () => { + writer.line('$data = [];'); + for (const property of ordered) { + const value = `$this->${propertyName(property.name)}`; + const wire = serialization(property.schema, value, model) ?? value; + if (property.required) { + writer.line(`$data[${phpString(property.name)}] = ${wire};`); + } + else { + writer.block(`if (${value} !== null) {`, () => { + writer.line(`$data[${phpString(property.name)}] = ${wire};`); + }, '}'); + } + } + writer.line('return $data;'); + }, '}'); + }, '}'); + writer.blank(); +} +/** Render every named schema: classes (allOf flattened), native enums, union dispatchers. */ +export function renderPhpModels(model) { + const writer = 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(writer, className(name), schema.description); + writer.block(`enum ${className(name)}: ${backing}`, () => { }, ''); + writer.block('{', () => { + asEnum.values.forEach((value) => { + const member = identifierFor(String(value), { style: 'pascal', reserved: PHP }); + const literal = typeof value === 'string' ? phpString(value) : String(value); + writer.line(`case ${member} = ${literal};`); + }); + }, '}'); + writer.blank(); + continue; + } + if (schema.kind === 'object' || schema.kind === 'intersection') { + const flat = flattenAllOf(schema, model); + if (flat !== undefined) { + writeClass(writer, name, flat.properties, model, 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(', '); + writer.line(`/** ${typeName} is a discriminated union (${phpString(cases.property)}): ${table}. */`); + writer.block(`function unmarshal${typeName}(array $data): mixed`, () => { }, ''); + writer.block('{', () => { + writer.block(`return match ($data[${phpString(cases.property)}] ?? null) {`, () => { + for (const entry of cases.cases) { + writer.line(`${phpString(entry.value)} => ${className(entry.schemaName)}::fromArray($data),`); + } + writer.line('default => $data,'); + }, '};'); + }, '}'); + writer.blank(); + continue; + } + // Everything else (plain unions, aliases, records) has no PHP declaration; + // references resolve to the underlying type via phpType. + } + return writer.toString(); +} +/** The op's primary JSON success schema, or undefined for void/no-body ops. */ +function successSchema(op) { + return op.successResponses.find((response) => response.contentType.toLowerCase().includes('json')) + ?.schema; +} +function sseResponse(op) { + return op.successResponses.find((response) => response.contentType.toLowerCase().includes('text/event-stream')); +} +function isMultipart(op) { + return op.requestBody?.contentType.toLowerCase().includes('multipart') ?? false; +} +function methodName(op) { + 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, model) { + if (op.security.length === 0) + return undefined; + const alternatives = op.security.map((andSet) => { + const specs = andSet.flatMap((key) => { + 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) { + 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(', ')}]`; +} +function methodArgs(op, model, includeBody) { + const pathArgs = op.pathParams.map((param) => ({ + php: propertyName(param.name), + wire: param.name, + type: phpType(param.schema, model), + })); + const queryArgs = op.queryParams.map((param) => ({ + php: propertyName(param.name), + wire: param.name, + type: phpType(param.schema, model), + })); + const signature = [ + ...pathArgs.map(({ php, type }) => `${type} ${'$'}${php}`), + ...(includeBody && op.requestBody + ? [`${isMultipart(op) ? 'array' : phpType(op.requestBody.schema, model)} ${'$'}body`] + : []), + ...queryArgs.map(({ php, type }) => { + const nullable = type === 'mixed' || type.startsWith('?') ? type : `?${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(writer, op, args) { + writer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`); + writer.line("[$authHeaders, $query, $cookies] = resolveAuth($op['security'] ?? [], $this->config->auth);"); + for (const { php, wire } of args.queryArgs) { + writer.block(`if (${'$'}${php} !== null) {`, () => { + writer.line(`$query[${phpString(wire)}] = ${'$'}${php};`); + }, '}'); + } + const pathDict = args.pathArgs + .map(({ php, wire }) => `${phpString(wire)} => ${'$'}${php}`) + .join(', '); + writer.line(`$url = buildUrl($this->config->serverUrl, $op['path'], [${pathDict}]);`); + writer.line('$requestHeaders = array_merge($authHeaders, $headers ?? []);'); + writer.block('if ($cookies !== []) {', () => { + writer.line("$requestHeaders['Cookie'] = implode('; ', $cookies);"); + }, '}'); +} +function writePhpMethod(writer, op, model) { + const args = methodArgs(op, model, true); + const sse = sseResponse(op); + const success = successSchema(op); + const returnType = sse !== undefined ? '\\Generator' : success === undefined ? 'void' : phpType(success, model); + writeDocComment(writer, methodName(op), op.summary ?? `${op.method.toUpperCase()} ${op.path}`); + writer.block(`public function ${methodName(op)}(${args.signature.join(', ')}): ${returnType}`, () => { }, ''); + writer.block('{', () => { + writeRequestSetup(writer, op, args); + if (sse !== undefined) { + const jsonData = sse.schema !== undefined && sse.schema.kind !== 'unknown'; + writer.line('$url = appendQuery($url, $query);'); + writer.block('$open = function (array $extraHeaders) use ($url, $requestHeaders): \\CurlHandle {', () => { + writer.line('$handle = curl_init($url);'); + writer.line('$lines = [];'); + writer.block('foreach (array_merge($requestHeaders, $extraHeaders) as $name => $value) {', () => { + writer.line("$lines[] = $name . ': ' . $value;"); + }, '}'); + writer.line('curl_setopt($handle, CURLOPT_HTTPHEADER, $lines);'); + writer.line('return $handle;'); + }, '};'); + writer.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)) { + writer.line('[$contentType, $encoded] = toMultipart($body);'); + request.push(`'body' => $encoded`, `'contentType' => $contentType`); + } + else if (op.requestBody) { + const wire = serialization(op.requestBody.schema, '$body', model) ?? '$body'; + writer.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`); + } + writer.line(`$response = send($this->config, [${request.join(', ')}]);`); + writer.block("if ($response['status'] >= 400) {", () => { + writer.line('throw apiErrorFrom($response);'); + }, '}'); + if (returnType === 'void') { + writer.line('decodeJson($response);'); + return; + } + const typed = success === undefined ? undefined : hydration(success, 'decodeJson($response)', model); + writer.line(`return ${typed ?? 'decodeJson($response)'};`); + }, '}'); + writer.blank(); +} +/** `Pages()` / `Items()` generators over the runtime's iterPages. */ +function writePhpPaginationWrappers(writer, op, model, pageHydration, itemHydration, itemsPointer) { + const args = methodArgs(op, model, false); + const name = methodName(op); + const writeCall = () => { + writer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`); + writer.line('$base = [];'); + for (const { php, wire } of args.queryArgs) { + writer.block(`if (${'$'}${php} !== null) {`, () => { + writer.line(`$base[${phpString(wire)}] = ${'$'}${php};`); + }, '}'); + } + const pathDict = args.pathArgs + .map(({ php, wire }) => `${phpString(wire)} => ${'$'}${php}`) + .join(', '); + writer.block('$call = function (array $params) use ($op, $headers): array {', () => { + writer.line("[$authHeaders, $authQuery, $cookies] = resolveAuth($op['security'] ?? [], $this->config->auth);"); + writer.line(`$url = buildUrl($this->config->serverUrl, $op['path'], [${pathDict}]);`); + writer.line('$requestHeaders = array_merge($authHeaders, $headers ?? []);'); + writer.block('if ($cookies !== []) {', () => { + writer.line("$requestHeaders['Cookie'] = implode('; ', $cookies);"); + }, '}'); + writer.line("$response = send($this->config, ['operationId' => $op['id'], 'method' => $op['method'], 'url' => $url, 'headers' => $requestHeaders, 'query' => array_merge($params, $authQuery)]);"); + writer.block("if ($response['status'] >= 400) {", () => { + writer.line('throw apiErrorFrom($response);'); + }, '}'); + writer.line('return [decodeJson($response), $response];'); + }, '};'); + }; + writer.line(`/** ${name} response pages, following the pagination rule automatically. */`); + writer.block(`public function ${name}Pages(${args.signature.join(', ')}): \\Generator`, () => { }, ''); + writer.block('{', () => { + writeCall(); + writer.block("foreach (iterPages($call, $op['pagination'], $base) as $page) {", () => { + writer.line(`yield ${pageHydration ?? '$page'};`); + }, '}'); + }, '}'); + writer.blank(); + writer.line(`/** The items of every ${name} page. */`); + writer.block(`public function ${name}Items(${args.signature.join(', ')}): \\Generator`, () => { }, ''); + writer.block('{', () => { + writeCall(); + writer.block("foreach (iterPages($call, $op['pagination'], $base) as $page) {", () => { + writer.line(`$items = resolvePointer($page, ${phpString(itemsPointer ?? '')});`); + writer.block('foreach (is_array($items) ? $items : [] as $item) {', () => { + writer.line(`yield ${itemHydration ?? '$item'};`); + }, '}'); + }, '}'); + }, '}'); + writer.blank(); +} +/** Drop the standalone header ( { + const writer = new Printer(' '); + const namespace = identifierFor(model.title, { style: 'pascal', reserved: PHP }); + writer.line('= 8.1, curl extension — zero Composer dependencies.'); + // CUSTOMIZATION: our platform banner — regeneration keeps it, `--update` merges around it. + writer.line('// Maintained by the Cafe platform team; see generators/php.mjs.'); + writer.blank(); + writer.line('declare(strict_types=1);'); + writer.blank(); + writer.line(`namespace ${namespace};`); + writer.blank(); + writer.line(renderPhpModels(model)); + writer.line('// ─── Embedded runtime (@redocly/client-generator php runtime) ───'); + writer.line(stripPhpHeader(PHP_RUNTIME_SOURCE)); + writer.blank(); + const operations = model.services.flatMap((service) => service.operations); + const paginationRules = new Map(); + for (const op of operations) { + const rule = paginationRuleFor(op, emit.pagination); + if (rule !== undefined) + paginationRules.set(op.name, rule); + } + writer.block('const OPERATIONS = [', () => { + for (const op of operations) { + const id = op.specName ?? op.name; + const security = phpSecurityLiteral(op, model); + const rule = paginationRules.get(op.name); + const fields = [ + `'id' => ${phpString(id)}`, + `'method' => ${phpString(op.method.toUpperCase())}`, + `'path' => ${phpString(op.path)}`, + ...(security !== undefined ? [`'security' => ${security}`] : []), + ...(rule !== undefined ? [`'pagination' => ${phpPaginationLiteral(rule)}`] : []), + ]; + writer.line(`${phpString(id)} => [${fields.join(', ')}],`); + } + }, '];'); + writer.blank(); + writeDocComment(writer, 'Client', `Client for ${model.title} (${model.version}).`); + writer.block('final class Client', () => { }, ''); + writer.block('{', () => { + writer.block('public function __construct(private Config $config)', () => { }, ''); + writer.block('{', () => { + writer.block("if ($this->config->serverUrl === '') {", () => { + writer.line(`$this->config->serverUrl = ${phpString(model.serverUrl ?? '')};`); + }, '}'); + }, '}'); + writer.blank(); + for (const op of operations) { + writePhpMethod(writer, op, model); + const rule = paginationRules.get(op.name); + if (rule === undefined) + continue; + const success = successSchema(op); + const pageHydration = success === undefined ? undefined : hydration(success, '$page', model); + // 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 itemHydration = element === undefined ? undefined : hydration(element, '$item', model); + writePhpPaginationWrappers(writer, op, model, pageHydration, itemHydration, rule.items); + } + }, '}'); + return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.php'), content: writer.toString() }]; +}; +/** One idiomatic PHP call per operation — feeds `x-codeSamples` for docs. */ +export function phpSample(op, ctx) { + const args = [ + ...op.pathParams.map((param) => `${phpString(`<${propertyName(param.name)}>`)}`), + ...(op.requestBody ? ['$body'] : []), + ...(op.queryParams.length > 0 + ? [`${propertyName(op.queryParams[0].name)}: ${phpString('')}`] + : []), + ]; + const namespace = identifierFor(ctx.model.title, { style: 'pascal', reserved: PHP }); + return { + lang: 'php', + label: 'PHP SDK', + source: `use ${namespace}\\{Client, Config};\n\n$client = new Client(new Config());\n$result = $client->${methodName(op)}(${args.join(', ')});\n`, + }; +} + +export default { + name: 'php', + run: phpGenerator, + sample: phpSample, +}; diff --git a/tests/e2e/generate-client/examples/ejected-generator/package.json b/tests/e2e/generate-client/examples/ejected-generator/package.json new file mode 100644 index 0000000000..40b1097693 --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/package.json @@ -0,0 +1,14 @@ +{ + "name": "@redocly-examples/ejected-generator", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "update-generator": "redocly eject-generator php --update", + "generate": "redocly generate-client" + }, + "devDependencies": { + "@redocly/cli": "latest", + "@redocly/client-generator": "latest" + } +} diff --git a/tests/e2e/generate-client/examples/ejected-generator/redocly.yaml b/tests/e2e/generate-client/examples/ejected-generator/redocly.yaml new file mode 100644 index 0000000000..7b950ab8b4 --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/redocly.yaml @@ -0,0 +1,9 @@ +# redocly.yaml — drives `redocly generate-client` for this example. +# The path entry takes over the built-in `php` name (the ejected generator shadows its origin). +apis: + ejected-generator: + root: ../_shared/cafe.yaml + clientOutput: ./src/api/client.ts + client: + generators: + - ./generators/php.mjs diff --git a/tests/e2e/generate-client/examples/fetch-functions/README.md b/tests/e2e/generate-client/examples/fetch-functions/README.md index e90c7e2b89..3bd5ece5f8 100644 --- a/tests/e2e/generate-client/examples/fetch-functions/README.md +++ b/tests/e2e/generate-client/examples/fetch-functions/README.md @@ -1,7 +1,6 @@ # fetch-functions example -Generated TypeScript client (`generators: ['sdk']`), consumed as free -functions (`configure()`, `listMenuItems()`), with `ApiError` handling. +Generated TypeScript client (`generators: ['typescript']`), consumed as free functions (`configure()`, `listMenuItems()`), with `ApiError` handling. ## Run diff --git a/tests/e2e/generate-client/examples/fetch-functions/redocly.yaml b/tests/e2e/generate-client/examples/fetch-functions/redocly.yaml index 0e7e6b995e..33d49a7e21 100644 --- a/tests/e2e/generate-client/examples/fetch-functions/redocly.yaml +++ b/tests/e2e/generate-client/examples/fetch-functions/redocly.yaml @@ -7,4 +7,4 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript diff --git a/tests/e2e/generate-client/examples/go-sdk/.gitignore b/tests/e2e/generate-client/examples/go-sdk/.gitignore new file mode 100644 index 0000000000..612acc5cae --- /dev/null +++ b/tests/e2e/generate-client/examples/go-sdk/.gitignore @@ -0,0 +1,3 @@ +node_modules +src/api/ +package-lock.json diff --git a/tests/e2e/generate-client/examples/go-sdk/README.md b/tests/e2e/generate-client/examples/go-sdk/README.md new file mode 100644 index 0000000000..d975b47ce9 --- /dev/null +++ b/tests/e2e/generate-client/examples/go-sdk/README.md @@ -0,0 +1,21 @@ +# go-sdk + +The `go` generator emits `src/api/client.go` — a full Go SDK over the standard library (zero dependencies, Go ≥ 1.21): + +- structs with `json` tags +- typed-const enums +- a context-aware `Client` with `(T, error)` methods +- auth +- retries +- pagination iterators (`Pages` / `Items`) +- SSE streaming +- multipart bodies + +```sh +npm run generate +go run . +``` + +The example calls the live demo API at `https://api.cafe.redocly.com` and prints three menu item names. +`MenuItem` is a discriminated union, so items arrive as `any`. +`UnmarshalMenuItem` dispatches them into `Beverage`/`Dessert` when you need the typed form. diff --git a/tests/e2e/generate-client/examples/go-sdk/go.mod b/tests/e2e/generate-client/examples/go-sdk/go.mod new file mode 100644 index 0000000000..9e02dcd7b9 --- /dev/null +++ b/tests/e2e/generate-client/examples/go-sdk/go.mod @@ -0,0 +1,3 @@ +module cafe.example + +go 1.21 diff --git a/tests/e2e/generate-client/examples/go-sdk/main.go b/tests/e2e/generate-client/examples/go-sdk/main.go new file mode 100644 index 0000000000..46494ade02 --- /dev/null +++ b/tests/e2e/generate-client/examples/go-sdk/main.go @@ -0,0 +1,23 @@ +// Consume the generated Go SDK: typed structs over the standard library. +package main + +import ( + "context" + "fmt" + + client "cafe.example/src/api" +) + +func main() { + api := client.New(client.Config{}) + limit := int64(3) + menu, err := api.ListMenuItems(context.Background(), &client.ListMenuItemsParams{Limit: &limit}) + if err != nil { + panic(err) + } + for _, item := range menu.Items { + if fields, ok := item.(map[string]any); ok { + fmt.Println(fields["name"]) + } + } +} diff --git a/tests/e2e/generate-client/examples/go-sdk/package.json b/tests/e2e/generate-client/examples/go-sdk/package.json new file mode 100644 index 0000000000..8e3dd612e5 --- /dev/null +++ b/tests/e2e/generate-client/examples/go-sdk/package.json @@ -0,0 +1,12 @@ +{ + "name": "@redocly-examples/go-sdk", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "generate": "redocly generate-client" + }, + "devDependencies": { + "@redocly/cli": "latest" + } +} diff --git a/tests/e2e/generate-client/examples/go-sdk/redocly.yaml b/tests/e2e/generate-client/examples/go-sdk/redocly.yaml new file mode 100644 index 0000000000..a3838b8924 --- /dev/null +++ b/tests/e2e/generate-client/examples/go-sdk/redocly.yaml @@ -0,0 +1,8 @@ +# redocly.yaml — drives `redocly generate-client` for this example. +apis: + go-sdk: + root: ../_shared/cafe.yaml + clientOutput: ./src/api/client.ts + client: + generators: + - go diff --git a/tests/e2e/generate-client/examples/mock/README.md b/tests/e2e/generate-client/examples/mock/README.md index 5cd9201929..3817d9a756 100644 --- a/tests/e2e/generate-client/examples/mock/README.md +++ b/tests/e2e/generate-client/examples/mock/README.md @@ -1,7 +1,6 @@ # mock example -Generated TypeScript client plus **MSW** mocks (`generators: ['sdk', 'mock']`), shown two ways from the -same generated `src/api/` and the same `handlers`: +Generated TypeScript client plus **MSW** mocks (`generators: ['typescript', 'mock']`), shown two ways from the same generated `src/api/` and the same `handlers`: - **Browser** (`src/main.ts`) — starts an MSW browser worker with `setupWorker` and renders the result. - **Node** (`src/node.ts`) — starts a server with `msw/node`'s `setupServer` and exports `loadMockedMenu()`. diff --git a/tests/e2e/generate-client/examples/mock/redocly.yaml b/tests/e2e/generate-client/examples/mock/redocly.yaml index 8a37c931fa..dc52fa997a 100644 --- a/tests/e2e/generate-client/examples/mock/redocly.yaml +++ b/tests/e2e/generate-client/examples/mock/redocly.yaml @@ -7,5 +7,5 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript - mock diff --git a/tests/e2e/generate-client/examples/mock/src/node.ts b/tests/e2e/generate-client/examples/mock/src/node.ts index 8567681783..d79d5e3813 100644 --- a/tests/e2e/generate-client/examples/mock/src/node.ts +++ b/tests/e2e/generate-client/examples/mock/src/node.ts @@ -1,4 +1,4 @@ -// Node counterpart to `main.ts`: the same generated `sdk` + `mock` client and the same +// Node counterpart to `main.ts`: the same generated `typescript` + `mock` client and the same // `handlers`, but driven by msw/node's `setupServer`. Node has no Service Worker, so msw // patches global `fetch` directly instead of registering `public/mockServiceWorker.js`. import { setupServer } from 'msw/node'; diff --git a/tests/e2e/generate-client/examples/multi-instance/redocly.yaml b/tests/e2e/generate-client/examples/multi-instance/redocly.yaml index eb1a5e90e7..dbc9df653b 100644 --- a/tests/e2e/generate-client/examples/multi-instance/redocly.yaml +++ b/tests/e2e/generate-client/examples/multi-instance/redocly.yaml @@ -7,5 +7,5 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript runtime: package diff --git a/tests/e2e/generate-client/examples/nested-facade/README.md b/tests/e2e/generate-client/examples/nested-facade/README.md index 4ea7dbb045..3bc7f93346 100644 --- a/tests/e2e/generate-client/examples/nested-facade/README.md +++ b/tests/e2e/generate-client/examples/nested-facade/README.md @@ -4,7 +4,11 @@ A resource-grouped call shape — `api.orders.listOrders(…)` — derived from spec's **tags** by a small [custom generator](./nested-facade-generator.mjs) (the experimental plugin API), so the nesting regenerates with the spec instead of living in a hand-maintained facade file. Everything stays fully typed: the -facade just re-exports the sdk's generated functions in nested objects. +A resource-grouped call shape — `api.orders.listOrders(…)` — derived from the +spec's **tags** by a small [custom generator](./nested-facade-generator.mjs) +(the experimental plugin API). +The nesting regenerates with the spec instead of living in a hand-maintained facade file. +Everything stays fully typed: the facade just re-exports the client's generated functions in nested objects. ## Run diff --git a/tests/e2e/generate-client/examples/nested-facade/nested-facade-generator.mjs b/tests/e2e/generate-client/examples/nested-facade/nested-facade-generator.mjs index ebee143162..e5d0073d47 100644 --- a/tests/e2e/generate-client/examples/nested-facade/nested-facade-generator.mjs +++ b/tests/e2e/generate-client/examples/nested-facade/nested-facade-generator.mjs @@ -1,10 +1,10 @@ -// A custom generator (the experimental plugin API): groups the sdk's generated +// A custom generator (the experimental plugin API): groups the client's generated // free functions by their first tag and emits a nested facade — // `api.orders.listOrders(…)` — derived from the spec, regenerated with it. // // Authored in TypeScript you would write: // import { defineGenerator } from '@redocly/client-generator'; -// export default defineGenerator({ name: 'nested-facade', requires: ['sdk'], run({ model, outputPath }) { … } }); +// export default defineGenerator({ name: 'nested-facade', requires: ['typescript'], run({ model, outputPath }) { … } }); const groupIdent = (tag) => { const ident = tag.replace(/[^A-Za-z0-9_$]/g, '_'); return /^[A-Za-z_$]/.test(ident) ? ident[0].toLowerCase() + ident.slice(1) : `_${ident}`; @@ -12,7 +12,7 @@ const groupIdent = (tag) => { export default { name: 'nested-facade', - requires: ['sdk'], + requires: ['typescript'], run({ model, outputPath }) { const groups = new Map(); for (const op of model.services.flatMap((service) => service.operations)) { diff --git a/tests/e2e/generate-client/examples/nested-facade/redocly.yaml b/tests/e2e/generate-client/examples/nested-facade/redocly.yaml index daf72c0a5b..e2c522315a 100644 --- a/tests/e2e/generate-client/examples/nested-facade/redocly.yaml +++ b/tests/e2e/generate-client/examples/nested-facade/redocly.yaml @@ -9,5 +9,5 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript - ./nested-facade-generator.mjs diff --git a/tests/e2e/generate-client/examples/nested-facade/src/main.ts b/tests/e2e/generate-client/examples/nested-facade/src/main.ts index db810ba43d..0449c70a69 100644 --- a/tests/e2e/generate-client/examples/nested-facade/src/main.ts +++ b/tests/e2e/generate-client/examples/nested-facade/src/main.ts @@ -1,7 +1,7 @@ import { api } from './api/client.facade.js'; // nested-facade — a resource-grouped client shape, generated from the spec's tags. // -// The generated sdk exposes flat functions and the `client` instance; some teams +// The generated client exposes flat functions and the `client` instance; some teams // prefer `api..(…)`. Instead of hand-maintaining that facade, // the custom generator in ./nested-facade-generator.mjs derives it from the spec's // tags — every regeneration keeps it in sync, and everything stays fully typed. diff --git a/tests/e2e/generate-client/examples/node-native/redocly.yaml b/tests/e2e/generate-client/examples/node-native/redocly.yaml index cab21aa9df..8ffd27e6f0 100644 --- a/tests/e2e/generate-client/examples/node-native/redocly.yaml +++ b/tests/e2e/generate-client/examples/node-native/redocly.yaml @@ -5,6 +5,6 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript outputMode: split importExt: ts diff --git a/tests/e2e/generate-client/examples/node-native/src/main.ts b/tests/e2e/generate-client/examples/node-native/src/main.ts index de372bc361..196751dcd0 100644 --- a/tests/e2e/generate-client/examples/node-native/src/main.ts +++ b/tests/e2e/generate-client/examples/node-native/src/main.ts @@ -5,7 +5,7 @@ // The import below uses a `.ts` extension for the same reason. import { listMenuItems } from './api/client.ts'; -const menu = await listMenuItems({ limit: 3 }); +const menu = await listMenuItems({ query: { limit: 3 } }); for (const item of menu.items) { console.log(`${item.name} — $${(item.price / 100).toFixed(2)}`); } diff --git a/tests/e2e/generate-client/examples/package-runtime/redocly.yaml b/tests/e2e/generate-client/examples/package-runtime/redocly.yaml index 5aa5ec8bbd..2ba6882016 100644 --- a/tests/e2e/generate-client/examples/package-runtime/redocly.yaml +++ b/tests/e2e/generate-client/examples/package-runtime/redocly.yaml @@ -8,5 +8,5 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript runtime: package diff --git a/tests/e2e/generate-client/examples/package-runtime/src/main.ts b/tests/e2e/generate-client/examples/package-runtime/src/main.ts index 0d074681f0..3d3c8af8b7 100644 --- a/tests/e2e/generate-client/examples/package-runtime/src/main.ts +++ b/tests/e2e/generate-client/examples/package-runtime/src/main.ts @@ -26,11 +26,14 @@ use({ async function main() { try { // A typed call through a generated free function… - const menu = await listMenuItems({ limit: 3 }); + const menu = await listMenuItems({ query: { limit: 3 } }); // …and one through the generated `client` instance (the same runtime underneath). const [first] = menu.items; const photo = first - ? await client.getMenuItemPhoto({ menuItemId: first.id, params: { photoSize: 'thumbnail' } }) + ? await client.getMenuItemPhoto({ + path: { menuItemId: first.id }, + query: { photoSize: 'thumbnail' }, + }) : undefined; const photoLine = photo instanceof Blob diff --git a/tests/e2e/generate-client/examples/pagination/redocly.yaml b/tests/e2e/generate-client/examples/pagination/redocly.yaml index 9d6b34bc23..832ddeebf0 100644 --- a/tests/e2e/generate-client/examples/pagination/redocly.yaml +++ b/tests/e2e/generate-client/examples/pagination/redocly.yaml @@ -10,7 +10,7 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript pagination: style: cursor cursorParam: cursor diff --git a/tests/e2e/generate-client/examples/pagination/src/main.ts b/tests/e2e/generate-client/examples/pagination/src/main.ts index 79a186ce54..62b2a47d47 100644 --- a/tests/e2e/generate-client/examples/pagination/src/main.ts +++ b/tests/e2e/generate-client/examples/pagination/src/main.ts @@ -5,7 +5,7 @@ // yield the array under `/orders`. The generator applies it only where it // STRUCTURALLY FITS — `listOrders` has the param and the pointers resolve, so it keeps // its one-shot call and gains `.pages()` / `.items()`; `getOrder` has no `cursor` -// param, so it stays a plain call. (Explicit declarations — `x-redocly-pagination` in the spec +// param, so it stays a plain call. (Explicit declarations — `x-redoclyPagination` in the spec // or per-operation config — that don't fit fail generation instead of being skipped.) import { configure, listOrders } from './api/client.js'; @@ -34,12 +34,12 @@ configure({ fetch: canned }); // `.items()` walks every order across every page — the cursor plumbing is invisible, // and each `order` is the statically computed element type (`Order`). -for await (const order of listOrders.items({ params: { limit: 20 } })) { +for await (const order of listOrders.items({ query: { limit: 20 } })) { console.log(`${order.id}: ${order.drink} (${order.status})`); } // `.pages()` when you need page-level access (progress reporting, batch writes). let pageNumber = 0; -for await (const page of listOrders.pages({ params: { limit: 20 } })) { +for await (const page of listOrders.pages({ query: { limit: 20 } })) { console.log(`page ${++pageNumber}: ${page.orders.length} orders`); } diff --git a/tests/e2e/generate-client/examples/php-sdk/.gitignore b/tests/e2e/generate-client/examples/php-sdk/.gitignore new file mode 100644 index 0000000000..612acc5cae --- /dev/null +++ b/tests/e2e/generate-client/examples/php-sdk/.gitignore @@ -0,0 +1,3 @@ +node_modules +src/api/ +package-lock.json diff --git a/tests/e2e/generate-client/examples/php-sdk/README.md b/tests/e2e/generate-client/examples/php-sdk/README.md new file mode 100644 index 0000000000..2f0f65063e --- /dev/null +++ b/tests/e2e/generate-client/examples/php-sdk/README.md @@ -0,0 +1,21 @@ +# php-sdk + +The `php` generator emits `src/api/client.php` — a full PHP SDK over the curl extension (zero Composer dependencies, PHP ≥ 8.1): + +- promoted-constructor classes with `fromArray`/`toArray` hydration +- native backed enums +- a `Client` with typed named-argument methods +- auth +- retries +- pagination generators (`Pages()` / `Items()`) +- SSE streaming +- multipart bodies + +The namespace derives from the API title (`RedoclyCafe` here). + +```sh +npm run generate +php src/main.php +``` + +The example calls the live demo API at `https://api.cafe.redocly.com` and prints three menu item names. diff --git a/tests/e2e/generate-client/examples/php-sdk/package.json b/tests/e2e/generate-client/examples/php-sdk/package.json new file mode 100644 index 0000000000..1f8d0b6554 --- /dev/null +++ b/tests/e2e/generate-client/examples/php-sdk/package.json @@ -0,0 +1,12 @@ +{ + "name": "@redocly-examples/php-sdk", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "generate": "redocly generate-client" + }, + "devDependencies": { + "@redocly/cli": "latest" + } +} diff --git a/tests/e2e/generate-client/examples/php-sdk/redocly.yaml b/tests/e2e/generate-client/examples/php-sdk/redocly.yaml new file mode 100644 index 0000000000..3463b52368 --- /dev/null +++ b/tests/e2e/generate-client/examples/php-sdk/redocly.yaml @@ -0,0 +1,8 @@ +# redocly.yaml — drives `redocly generate-client` for this example. +apis: + php-sdk: + root: ../_shared/cafe.yaml + clientOutput: ./src/api/client.ts + client: + generators: + - php diff --git a/tests/e2e/generate-client/examples/php-sdk/src/main.php b/tests/e2e/generate-client/examples/php-sdk/src/main.php new file mode 100644 index 0000000000..f4a992f6fc --- /dev/null +++ b/tests/e2e/generate-client/examples/php-sdk/src/main.php @@ -0,0 +1,15 @@ +listMenuItems(limit: 3); +foreach ($menu->items as $item) { + echo $item['name'], PHP_EOL; +} diff --git a/tests/e2e/generate-client/examples/programmatic/generate.ts b/tests/e2e/generate-client/examples/programmatic/generate.ts index 2984185f0f..c1ddd005ad 100644 --- a/tests/e2e/generate-client/examples/programmatic/generate.ts +++ b/tests/e2e/generate-client/examples/programmatic/generate.ts @@ -13,7 +13,7 @@ const result = await generateClient({ outputMode: 'single', // 'single' | 'split' argsStyle: 'flat', // 'flat' | 'grouped' errorMode: 'throw', // 'throw' | 'result' - generators: ['sdk'], // add 'zod' | 'tanstack-query' | 'transformers' + generators: ['typescript'], // add 'zod' | 'tanstack-query' | 'transformers' }); console.log(`Wrote ${result.files.length} file(s), ${result.bytes} bytes to ${result.outputPath}`); diff --git a/tests/e2e/generate-client/examples/python-sdk/.gitignore b/tests/e2e/generate-client/examples/python-sdk/.gitignore new file mode 100644 index 0000000000..9f2ae7a01e --- /dev/null +++ b/tests/e2e/generate-client/examples/python-sdk/.gitignore @@ -0,0 +1,4 @@ +node_modules +src/api/ +package-lock.json +__pycache__/ diff --git a/tests/e2e/generate-client/examples/python-sdk/README.md b/tests/e2e/generate-client/examples/python-sdk/README.md new file mode 100644 index 0000000000..439f8a2c4c --- /dev/null +++ b/tests/e2e/generate-client/examples/python-sdk/README.md @@ -0,0 +1,24 @@ +# python-sdk + +The `python` generator emits `src/api/client.py`. +`client.docs: true` (the `--docs` flag) is set here too, so the generator also writes its own reference: `src/api/client.python.md` — every operation with its parameters, body, response type, and a Python call sample. +'It is a full Python SDK over [httpx](https://www.python-httpx.org/) (Python ≥ 3.9): + +- typed dataclass models +- sync `Client` +- async `AsyncClient` +- auth +- retries +- pagination iterators (`_pages()` / `_items()`) +- SSE streaming +- multipart bodies + +No TypeScript is involved — a `python`-only selection never loads the `typescript` package. + +```sh +npm run generate +pip install httpx +python src/main.py +``` + +The example calls the live demo API at `https://api.cafe.redocly.com` and prints three menu item names. diff --git a/tests/e2e/generate-client/examples/python-sdk/package.json b/tests/e2e/generate-client/examples/python-sdk/package.json new file mode 100644 index 0000000000..2d2b76b025 --- /dev/null +++ b/tests/e2e/generate-client/examples/python-sdk/package.json @@ -0,0 +1,12 @@ +{ + "name": "@redocly-examples/python-sdk", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "generate": "redocly generate-client" + }, + "devDependencies": { + "@redocly/cli": "latest" + } +} diff --git a/tests/e2e/generate-client/examples/python-sdk/redocly.yaml b/tests/e2e/generate-client/examples/python-sdk/redocly.yaml new file mode 100644 index 0000000000..df5b32da7c --- /dev/null +++ b/tests/e2e/generate-client/examples/python-sdk/redocly.yaml @@ -0,0 +1,10 @@ +# redocly.yaml — drives `redocly generate-client` for this example. +apis: + python-sdk: + root: ../_shared/cafe.yaml + clientOutput: ./src/api/client.ts + client: + generators: + - python + # One switch documents whatever the run generates: `src/api/client.python.md`. + docs: true diff --git a/tests/e2e/generate-client/examples/python-sdk/src/main.py b/tests/e2e/generate-client/examples/python-sdk/src/main.py new file mode 100644 index 0000000000..d9c40068e1 --- /dev/null +++ b/tests/e2e/generate-client/examples/python-sdk/src/main.py @@ -0,0 +1,12 @@ +# Consume the generated Python SDK: typed dataclasses over httpx. +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent / "api")) + +from client import Client + +client = Client() +menu = client.list_menu_items(limit=3) +for item in menu.items: + print(item.name) diff --git a/tests/e2e/generate-client/examples/sse-streaming/redocly.yaml b/tests/e2e/generate-client/examples/sse-streaming/redocly.yaml index 29ff096d03..ae16139b8e 100644 --- a/tests/e2e/generate-client/examples/sse-streaming/redocly.yaml +++ b/tests/e2e/generate-client/examples/sse-streaming/redocly.yaml @@ -5,4 +5,4 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript diff --git a/tests/e2e/generate-client/examples/tanstack-query/README.md b/tests/e2e/generate-client/examples/tanstack-query/README.md index 19fbc36c6b..084fa6bf60 100644 --- a/tests/e2e/generate-client/examples/tanstack-query/README.md +++ b/tests/e2e/generate-client/examples/tanstack-query/README.md @@ -1,7 +1,7 @@ # tanstack-query example Generated TypeScript client plus **TanStack Query** (React) factories -(`generators: ['sdk', 'tanstack-query']`). The app uses `useQuery(Options())` under a +(`generators: ['typescript', 'tanstack-query']`). The app uses `useQuery(Options())` under a `QueryClientProvider`. ## Run @@ -12,4 +12,5 @@ npm run generate # generate src/api (the client is gitignored) npm run dev # open the printed local URL ``` -The generated client + TanStack factories under `src/api/` are gitignored; CI regenerates them and type-checks this example. +The generated client + TanStack factories under `src/api/` are gitignored. +CI regenerates them and type-checks this example. diff --git a/tests/e2e/generate-client/examples/tanstack-query/redocly.yaml b/tests/e2e/generate-client/examples/tanstack-query/redocly.yaml index 7572c29f9d..053b6cc28f 100644 --- a/tests/e2e/generate-client/examples/tanstack-query/redocly.yaml +++ b/tests/e2e/generate-client/examples/tanstack-query/redocly.yaml @@ -7,5 +7,5 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript - tanstack-query diff --git a/tests/e2e/generate-client/examples/ast-toolkit-generator/.gitignore b/tests/e2e/generate-client/examples/typescript-types-generator/.gitignore similarity index 100% rename from tests/e2e/generate-client/examples/ast-toolkit-generator/.gitignore rename to tests/e2e/generate-client/examples/typescript-types-generator/.gitignore diff --git a/tests/e2e/generate-client/examples/ast-toolkit-generator/README.md b/tests/e2e/generate-client/examples/typescript-types-generator/README.md similarity index 67% rename from tests/e2e/generate-client/examples/ast-toolkit-generator/README.md rename to tests/e2e/generate-client/examples/typescript-types-generator/README.md index ed1c1b7a12..3cb5d7857b 100644 --- a/tests/e2e/generate-client/examples/ast-toolkit-generator/README.md +++ b/tests/e2e/generate-client/examples/typescript-types-generator/README.md @@ -1,13 +1,13 @@ -# AST toolkit generator example +# TypeScript types generator example -A custom generator that builds its output as a real TypeScript AST with the -`@redocly/client-generator/generate` entry — the same `ts.factory` + printer toolkit the built-in -generators use — instead of concatenating strings -(compare with the string-building [`custom-generator`](../custom-generator) example). +A custom generator that renders real TypeScript types with the `@redocly/client-generator/generate` entry. +This is the same type renderer the built-in generators use. +The mapping matches the generated client exactly, instead of guessing at type text. +Compare with the plain string-building [`custom-generator`](../custom-generator) example. - [`response-map-generator.mjs`](./response-map-generator.mjs) — the generator. For every operation with a JSON success response it derives the response body's TypeScript type - with `schemaToTypeNode` and prints `src/api/client.responses.ts`: + with `tsType` and renders `src/api/client.responses.ts`: ```ts import type { MenuItemList, Order, OrderItem } from './client.js'; @@ -20,7 +20,7 @@ generators use — instead of concatenating strings }; ``` -- [`redocly.yaml`](./redocly.yaml) — `generators: [sdk, ./response-map-generator.mjs]`. +- [`redocly.yaml`](./redocly.yaml) — `generators: [typescript, ./response-map-generator.mjs]`. - [`src/main.ts`](./src/main.ts) — proves the map matches the client: `ResponseShapes['listMenuItems']` is exactly what `listMenuItems()` resolves to. @@ -31,8 +31,7 @@ runtime-only. The `/generate` entry holds everything that runs at **generation time** — it loads the TypeScript compiler and `@redocly/openapi-core`, which an app must never pull in: -- the emit toolkit used here (`ts`, `printStatements`, `parseStatements`, `operationSignature`, - `schemaToTypeNode`, `pascalCase`, …), +- the text toolkit used here (`tsType`, `tsJsdoc`, `codeLiteral`, `operationSignature`, `pascalCase`, …), - `generateClient` (also re-exported from the root behind a dynamic import) and `collectGeneratedFiles` for in-memory generation. diff --git a/tests/e2e/generate-client/examples/ast-toolkit-generator/package.json b/tests/e2e/generate-client/examples/typescript-types-generator/package.json similarity index 81% rename from tests/e2e/generate-client/examples/ast-toolkit-generator/package.json rename to tests/e2e/generate-client/examples/typescript-types-generator/package.json index 7c58fdcb86..bae6b1c41e 100644 --- a/tests/e2e/generate-client/examples/ast-toolkit-generator/package.json +++ b/tests/e2e/generate-client/examples/typescript-types-generator/package.json @@ -1,5 +1,5 @@ { - "name": "@redocly-examples/ast-toolkit-generator", + "name": "@redocly-examples/typescript-types-generator", "private": true, "version": "0.0.0", "type": "module", diff --git a/tests/e2e/generate-client/examples/ast-toolkit-generator/redocly.yaml b/tests/e2e/generate-client/examples/typescript-types-generator/redocly.yaml similarity index 87% rename from tests/e2e/generate-client/examples/ast-toolkit-generator/redocly.yaml rename to tests/e2e/generate-client/examples/typescript-types-generator/redocly.yaml index 5beeef9dea..b88da353a4 100644 --- a/tests/e2e/generate-client/examples/ast-toolkit-generator/redocly.yaml +++ b/tests/e2e/generate-client/examples/typescript-types-generator/redocly.yaml @@ -2,10 +2,10 @@ # The client is generated for the api that declares a `client` block # (run `redocly generate-client` with no args to build every such api). apis: - ast-toolkit-generator: + typescript-types-generator: root: ../_shared/cafe.yaml clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript - ./response-map-generator.mjs diff --git a/tests/e2e/generate-client/examples/ast-toolkit-generator/response-map-generator.mjs b/tests/e2e/generate-client/examples/typescript-types-generator/response-map-generator.mjs similarity index 57% rename from tests/e2e/generate-client/examples/ast-toolkit-generator/response-map-generator.mjs rename to tests/e2e/generate-client/examples/typescript-types-generator/response-map-generator.mjs index b99efafd41..0f5a8a34bd 100644 --- a/tests/e2e/generate-client/examples/ast-toolkit-generator/response-map-generator.mjs +++ b/tests/e2e/generate-client/examples/typescript-types-generator/response-map-generator.mjs @@ -1,23 +1,20 @@ -// A custom generator that builds its output as a real TypeScript AST with the -// `@redocly/client-generator/generate` toolkit — the same `ts.factory` + printer the -// built-in generators use — instead of concatenating strings. It emits -// `.responses.ts`: a `ResponseShapes` type mapping every operation to the -// TypeScript type of its primary JSON success body. `schemaToTypeNode` does the -// schema→type mapping (refs, arrays, unions, formats) exactly as the sdk does, and the -// printer gets quoting and type syntax right for free. +// A custom generator that renders real TypeScript TYPES with the +// `@redocly/client-generator/generate` text toolkit — `tsType` is the same +// schema→type renderer the built-in typescript generator uses (refs, arrays, unions, formats, +// parenthesization), so the output matches the generated client's types exactly. +// It emits `.responses.ts`: a `ResponseShapes` type mapping every +// operation to the TypeScript type of its primary JSON success body. // // Plain ESM so the CLI imports it under bare `node`. Authored in TypeScript you would write: // // import { defineGenerator } from '@redocly/client-generator'; -// import { printStatements, schemaToTypeNode, ts } from '@redocly/client-generator/generate'; -// export default defineGenerator({ name: 'response-map', requires: ['sdk'], run({ model, outputPath }) { … } }); -import { printStatements, schemaToTypeNode, ts } from '@redocly/client-generator/generate'; - -const { factory } = ts; +// import { tsType } from '@redocly/client-generator/generate'; +// export default defineGenerator({ name: 'response-map', requires: ['typescript'], run({ model, outputPath }) { … } }); +import { tsType } from '@redocly/client-generator/generate'; export default { name: 'response-map', - requires: ['sdk'], + requires: ['typescript'], run({ model, outputPath }) { // Every operation with a JSON success body — a 204 or an image download has no entry. const withJsonBody = model.services @@ -29,18 +26,11 @@ export default { return success ? [{ name: op.name, schema: success.schema }] : []; }); - const responseShapes = factory.createTypeAliasDeclaration( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - 'ResponseShapes', - undefined, - factory.createTypeLiteralNode( - withJsonBody.map(({ name, schema }) => - factory.createPropertySignature(undefined, name, undefined, schemaToTypeNode(schema)) - ) - ) + const members = withJsonBody.map( + ({ name, schema }) => ` ${name}: ${tsType(schema, 'string', ' ')};` ); - // `schemaToTypeNode` renders a `$ref` as a bare type reference, so the module + // `tsType` renders a `$ref` as a bare type reference, so the module // type-imports every referenced schema name from the generated client. const referenced = [...new Set(withJsonBody.flatMap(({ schema }) => refNames(schema)))].sort(); const importLine = @@ -54,7 +44,7 @@ export default { content: '// Generated by the response-map custom generator. Do not edit by hand.\n' + importLine + - printStatements([responseShapes]) + + `export type ResponseShapes = {\n${members.join('\n')}\n};` + '\n', }, ]; diff --git a/tests/e2e/generate-client/examples/ast-toolkit-generator/src/main.ts b/tests/e2e/generate-client/examples/typescript-types-generator/src/main.ts similarity index 83% rename from tests/e2e/generate-client/examples/ast-toolkit-generator/src/main.ts rename to tests/e2e/generate-client/examples/typescript-types-generator/src/main.ts index 48fdc47865..e6b8031e5d 100644 --- a/tests/e2e/generate-client/examples/ast-toolkit-generator/src/main.ts +++ b/tests/e2e/generate-client/examples/typescript-types-generator/src/main.ts @@ -1,4 +1,4 @@ -// Consumes the sdk client alongside the custom generator's `ResponseShapes` map — +// Consumes the typescript client alongside the custom generator's `ResponseShapes` map — // the annotation below only compiles because the map's entry IS the type // `listMenuItems()` resolves to, proving the AST-built output stays in sync with the client. import { configure, listMenuItems } from './api/client.js'; diff --git a/tests/e2e/generate-client/examples/ast-toolkit-generator/tsconfig.json b/tests/e2e/generate-client/examples/typescript-types-generator/tsconfig.json similarity index 100% rename from tests/e2e/generate-client/examples/ast-toolkit-generator/tsconfig.json rename to tests/e2e/generate-client/examples/typescript-types-generator/tsconfig.json diff --git a/tests/e2e/generate-client/examples/valibot-generator/.gitignore b/tests/e2e/generate-client/examples/valibot-generator/.gitignore new file mode 100644 index 0000000000..612acc5cae --- /dev/null +++ b/tests/e2e/generate-client/examples/valibot-generator/.gitignore @@ -0,0 +1,3 @@ +node_modules +src/api/ +package-lock.json diff --git a/tests/e2e/generate-client/examples/valibot-generator/README.md b/tests/e2e/generate-client/examples/valibot-generator/README.md new file mode 100644 index 0000000000..4f330fac62 --- /dev/null +++ b/tests/e2e/generate-client/examples/valibot-generator/README.md @@ -0,0 +1,45 @@ +# valibot-generator + +A custom generator that emits [Valibot](https://valibot.dev) schemas beside the client, in about 60 lines. + +The built-in validation generator emits [Zod](https://zod.dev) schemas. +This example exists to show what to do when the built-ins do not cover the library you use: you write the generator, and it runs in the same pass as the built-in ones. + +```bash +npm run generate # redocly generate-client +``` + +That writes two files from one description: + +- `src/api/client.ts` — the typed client, from the built-in `typescript` generator. +- `src/api/client.valibot.ts` — one `Schema` per named schema, plus the inferred type, from [`valibot-schema-generator.mjs`](./valibot-schema-generator.mjs). + +`src/main.ts` uses both: the client types the call, and `v.parse` checks the value at run time. + +## What the generator shows + +- **The API model is the input.** `model.schemas` is the list of named schemas, each already resolved. +- **Composition is solved for you.** `flattenAllOf` merges an `allOf` chain into one property list, so the generator never implements composition semantics. +- **Enums come with their values.** `enumValues` returns them, and `v.picklist` takes them directly. +- **`Printer` builds the text.** No template language, and no whitespace bookkeeping. +- **Metadata carries `format`.** A `format: binary` property is a `Blob` in the client, so the schema uses `v.blob()`. + A generator that ignored `format` would emit a schema that disagrees with the client's own type, and this example's `tsc` bar would fail. + +Nothing here is privileged: the built-in `zod` generator has the same shape, and this file could be published as a package or committed in your repo. + +## Configuration + +The generator is selected by path, next to a built-in name: + +```yaml +apis: + valibot-generator: + root: ../_shared/cafe.yaml + clientOutput: ./src/api/client.ts + client: + generators: + - typescript + - ./valibot-schema-generator.mjs +``` + +See [Customize client generation](https://redocly.com/docs/cli/guides/customize-client-generation) for the full contract: declared options, the helper table, compatibility ranges, and ejecting a built-in generator to start from its code instead of a blank file. diff --git a/tests/e2e/generate-client/examples/valibot-generator/package.json b/tests/e2e/generate-client/examples/valibot-generator/package.json new file mode 100644 index 0000000000..0cf3dcb956 --- /dev/null +++ b/tests/e2e/generate-client/examples/valibot-generator/package.json @@ -0,0 +1,14 @@ +{ + "name": "@redocly-examples/valibot-generator", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "generate": "redocly generate-client" + }, + "devDependencies": { + "@redocly/cli": "latest", + "typescript": "^5.5.0", + "valibot": "^1.4.0" + } +} diff --git a/tests/e2e/generate-client/examples/valibot-generator/redocly.yaml b/tests/e2e/generate-client/examples/valibot-generator/redocly.yaml new file mode 100644 index 0000000000..ed2364b961 --- /dev/null +++ b/tests/e2e/generate-client/examples/valibot-generator/redocly.yaml @@ -0,0 +1,10 @@ +# redocly.yaml — drives `redocly generate-client` for this example. +# The custom generator is selected by path, beside the built-in `typescript`. +apis: + valibot-generator: + root: ../_shared/cafe.yaml + clientOutput: ./src/api/client.ts + client: + generators: + - typescript + - ./valibot-schema-generator.mjs diff --git a/tests/e2e/generate-client/examples/valibot-generator/src/main.ts b/tests/e2e/generate-client/examples/valibot-generator/src/main.ts new file mode 100644 index 0000000000..23673a9dcd --- /dev/null +++ b/tests/e2e/generate-client/examples/valibot-generator/src/main.ts @@ -0,0 +1,17 @@ +// The generated client, plus schemas from a validation library the built-ins do not cover. +// Both come from one description and one `redocly generate-client` run. +import * as v from 'valibot'; + +import { listMenuItems } from './api/client.js'; +import { MenuItemListSchema, type MenuItemList } from './api/client.valibot.js'; + +const menu: MenuItemList = await listMenuItems(); + +// The client already types this value from the description. The schema checks it at run +// time, which is what catches a server that has drifted from the description. +const checked = v.parse(MenuItemListSchema, menu); +console.log(`${checked.items?.length ?? 0} items`); + +// `v.safeParse` for the non-throwing shape, the same as any hand-written Valibot code. +const result = v.safeParse(MenuItemListSchema, { items: 'not an array' }); +if (!result.success) console.log(`rejected: ${result.issues[0].message}`); diff --git a/tests/e2e/generate-client/examples/valibot-generator/tsconfig.json b/tests/e2e/generate-client/examples/valibot-generator/tsconfig.json new file mode 100644 index 0000000000..4bd6962d40 --- /dev/null +++ b/tests/e2e/generate-client/examples/valibot-generator/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["src"] +} diff --git a/tests/e2e/generate-client/examples/valibot-generator/valibot-schema-generator.mjs b/tests/e2e/generate-client/examples/valibot-generator/valibot-schema-generator.mjs new file mode 100644 index 0000000000..14f2c9f6ae --- /dev/null +++ b/tests/e2e/generate-client/examples/valibot-generator/valibot-schema-generator.mjs @@ -0,0 +1,75 @@ +// A custom generator that emits Valibot schemas, one per named schema in the description. +// +// It exists to answer a question people ask about every code generator: "you support the +// validation library I don't use — now what?" The answer is this file. It is ~60 lines over +// the authoring toolkit, it ships no new dependency into the generated client, and nothing +// in it is privileged: the built-in `zod` generator is the same shape, only longer. +// +// Plain ESM so the CLI imports it under bare `node`. In TypeScript you would write: +// +// import { defineGenerator, flattenAllOf, enumValues } from '@redocly/client-generator'; +// export default defineGenerator({ name: 'valibot', run({ model, outputPath }) { … } }); +// +// `defineGenerator` only supplies types, so a plain object works the same. +import { enumValues, flattenAllOf, Printer } from '@redocly/client-generator'; + +/** A schema from the API model, as a Valibot expression. */ +function valibotSchema(schema, model) { + const asEnum = enumValues(schema); + if (asEnum !== undefined) { + return `v.picklist([${asEnum.values.map((value) => JSON.stringify(value)).join(', ')}])`; + } + switch (schema.kind) { + case 'scalar': + if (schema.scalar === 'integer' || schema.scalar === 'number') return 'v.number()'; + if (schema.scalar === 'boolean') return 'v.boolean()'; + // `format` rides on the schema metadata, so a generator can follow the same + // decisions the built-in generators make. `binary` is a `Blob` in the client, and a + // schema that called it a string would disagree with the type on every call site. + if (schema.metadata?.format === 'binary') return 'v.blob()'; + return 'v.string()'; + case 'literal': + return `v.literal(${JSON.stringify(schema.value)})`; + case 'array': + return `v.array(${valibotSchema(schema.items, model)})`; + case 'record': + return `v.record(v.string(), ${valibotSchema(schema.value, model)})`; + // A reference points at another emitted schema; `v.lazy` keeps a recursive one legal. + case 'ref': + return `v.lazy(() => ${schema.name}Schema)`; + case 'union': + return `v.union([${schema.members.map((member) => valibotSchema(member, model)).join(', ')}])`; + case 'null': + return 'v.null()'; + case 'object': + case 'intersection': { + // `flattenAllOf` merges an allOf composition into one property list, so this + // generator never implements composition semantics itself. + const flat = flattenAllOf(schema, model) ?? { properties: schema.properties ?? [] }; + const entries = flat.properties.map((property) => { + const inner = valibotSchema(property.schema, model); + const value = property.required ? inner : `v.optional(${inner})`; + return ` ${JSON.stringify(property.name)}: ${value},`; + }); + return entries.length === 0 ? 'v.object({})' : `v.object({\n${entries.join('\n')}\n})`; + } + default: + return 'v.unknown()'; + } +} + +export default { + name: 'valibot', + run({ model, outputPath }) { + const printer = new Printer(); + printer.line('// Generated by the valibot custom generator. Do not edit by hand.'); + printer.line("import * as v from 'valibot';"); + printer.blank(); + for (const { name, schema } of model.schemas) { + printer.line(`export const ${name}Schema = ${valibotSchema(schema, model)};`); + printer.line(`export type ${name} = v.InferOutput;`); + printer.blank(); + } + return [{ path: outputPath.replace(/\.ts$/, '.valibot.ts'), content: printer.toString() }]; + }, +}; diff --git a/tests/e2e/generate-client/examples/vendored-edge/redocly.yaml b/tests/e2e/generate-client/examples/vendored-edge/redocly.yaml index 7bf35132be..88d4da2c8b 100644 --- a/tests/e2e/generate-client/examples/vendored-edge/redocly.yaml +++ b/tests/e2e/generate-client/examples/vendored-edge/redocly.yaml @@ -7,4 +7,4 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript diff --git a/tests/e2e/generate-client/examples/vendored-edge/worker.ts b/tests/e2e/generate-client/examples/vendored-edge/worker.ts index d395a7868a..46971f3f2e 100644 --- a/tests/e2e/generate-client/examples/vendored-edge/worker.ts +++ b/tests/e2e/generate-client/examples/vendored-edge/worker.ts @@ -14,15 +14,15 @@ export default { try { if (url.pathname === '/menu') { const menu = await client.listMenuItems({ - params: { search: url.searchParams.get('search') ?? undefined }, + query: { search: url.searchParams.get('search') ?? undefined }, }); return Response.json(menu.items); } const photo = url.pathname.match(/^\/photo\/(?[^/]+)$/); if (photo?.groups) { const image = await client.getMenuItemPhoto({ - menuItemId: photo.groups.menuItemId, - params: { photoSize: 'thumbnail' }, + path: { menuItemId: photo.groups.menuItemId }, + query: { photoSize: 'thumbnail' }, }); return image instanceof Blob ? new Response(image, { headers: { 'content-type': image.type } }) diff --git a/tests/e2e/generate-client/examples/zero-install-quickstart/redocly.yaml b/tests/e2e/generate-client/examples/zero-install-quickstart/redocly.yaml index be28cd8da3..a19c468869 100644 --- a/tests/e2e/generate-client/examples/zero-install-quickstart/redocly.yaml +++ b/tests/e2e/generate-client/examples/zero-install-quickstart/redocly.yaml @@ -5,4 +5,4 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript diff --git a/tests/e2e/generate-client/examples/zero-install-quickstart/src/api/client.ts b/tests/e2e/generate-client/examples/zero-install-quickstart/src/api/client.ts index 633a545c95..80d9f65886 100644 --- a/tests/e2e/generate-client/examples/zero-install-quickstart/src/api/client.ts +++ b/tests/e2e/generate-client/examples/zero-install-quickstart/src/api/client.ts @@ -45,7 +45,7 @@ export type OAuth2Client = { export type ListMenuItemsResult = MenuItemList; -export type ListMenuItemsParams = { +export type ListMenuItemsQuery = { /** * Case-insensitive substring match on item names. */ @@ -59,12 +59,19 @@ export type ListMenuItemsParams = { }; export type ListMenuItemsVariables = { - params?: ListMenuItemsParams; + query?: ListMenuItemsQuery; }; export type GetMenuItemPhotoResult = Blob | string; -export type GetMenuItemPhotoParams = { +export type GetMenuItemPhotoPath = { + /** + * ID of the menu item. + */ + menuItemId: string; +}; + +export type GetMenuItemPhotoQuery = { /** * Photo size to retrieve. */ @@ -72,11 +79,8 @@ export type GetMenuItemPhotoParams = { }; export type GetMenuItemPhotoVariables = { - /** - * ID of the menu item. - */ - menuItemId: string; - params?: GetMenuItemPhotoParams; + path: GetMenuItemPhotoPath; + query?: GetMenuItemPhotoQuery; }; export type RegisterOAuth2ClientResult = OAuth2Client; @@ -94,17 +98,14 @@ export type RegisterOAuth2ClientVariables = { export type Ops = { listMenuItems: { args: { - params?: ListMenuItemsParams; + query?: ListMenuItemsQuery; }; result: ListMenuItemsResult; }; getMenuItemPhoto: { args: { - /** - * ID of the menu item. - */ - menuItemId: string; - params?: GetMenuItemPhotoParams; + path: GetMenuItemPhotoPath; + query?: GetMenuItemPhotoQuery; }; result: GetMenuItemPhotoResult; }; @@ -210,6 +211,12 @@ export type OperationDescriptor = { /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */ security?: readonly (readonly SecuritySpec[])[]; pagination?: PaginationSpec; + /** + * `'grouped'` marks an operation that takes its inputs namespaced by layer even on a + * `argsStyle: 'flat'` client — the generator sets it where a merged call could not carry + * one name for two layers, and the operation's own input type says the same. + */ + argsStyle?: 'grouped'; /** * Declared success-response headers for throw-mode `{ envelope: true }`. * `name` is the lowercased wire name; `key` is the camelCase envelope property. @@ -338,6 +345,12 @@ export type ClientConfig = { auth?: AuthCredentials; /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */ errorMode?: 'throw' | 'result'; + /** + * How each call spells its inputs: `'grouped'` (the default) namespaces them by layer — + * `{ path, query, headers, cookies, body }` — and `'flat'` takes one merged object. + * Fixed at generate time, like `errorMode`, because it shapes the static types. + */ + argsStyle?: 'grouped' | 'flat'; onRequest?: Middleware['onRequest']; onResponse?: Middleware['onResponse']; onError?: Middleware['onError']; @@ -1034,14 +1047,66 @@ type Capabilities = SendCapabilities & { }; }; -/** The grouped args wire shape: path params by name plus the `params`/`body`/`headers`/`cookies` slots. */ +/** + * One call's inputs, namespaced by transport layer. `argsStyle: 'flat'` clients accept the + * merged form instead (every parameter and body property at one level) — `namespaceArgs` + * converts it to this shape before anything downstream reads it. + */ type OperationArgs = { - params?: Record; + path?: Record; + query?: Record; body?: unknown; headers?: Record; cookies?: Record; } & Record; +/** The five layer keys, and the only top-level keys a namespaced call may carry. */ +const LAYERS: readonly string[] = ['path', 'query', 'body', 'headers', 'cookies']; + +/** Where a declared parameter's `in` value puts it. */ +const LAYER_OF: Record = { + path: 'path', + query: 'query', + header: 'headers', + cookie: 'cookies', +}; + +/** + * Merged (`argsStyle: 'flat'`) args → the namespaced shape. A key that names a declared + * parameter goes to that parameter's layer; anything else is a property of the request + * body, which is how a flat call spells an object body. `body` stays reserved for the + * operations a flat call cannot merge (an array, a scalar, or a binary body). + */ +function namespaceArgs(op: OperationDescriptor, args: OperationArgs): OperationArgs { + const layers: Record> = {}; + let body: unknown; + let properties: Record | undefined; + const layerOfParam = new Map((op.params ?? []).map((param) => [param.name, param.in])); + for (const [key, value] of Object.entries(args)) { + const layer = LAYER_OF[layerOfParam.get(key) ?? '']; + if (layer !== undefined) { + (layers[layer] ??= {})[key] = value; + } else if (key === 'body' && op.body !== undefined) { + body = value; + } else if (op.body !== undefined) { + (properties ??= {})[key] = value; + } else { + throw new TypeError( + `Unknown argument "${key}" for operation "${op.id}": it names no declared parameter, and the operation takes no request body.` + ); + } + } + const namespaced: OperationArgs = {}; + if (layers.path) namespaced.path = layers.path; + // The flat surface types every query value, so the collected bag is one by construction. + if (layers.query) namespaced.query = layers.query as Record; + if (layers.headers) namespaced.headers = layers.headers; + if (layers.cookies) namespaced.cookies = layers.cookies; + if (properties !== undefined) namespaced.body = properties; + else if (body !== undefined) namespaced.body = body; + return namespaced; +} + /** The response reader implied by the descriptor (before any per-call `parseAs` override). */ /** * The `Accept` header matching how the response will be read — a blob/text operation @@ -1064,31 +1129,35 @@ function kindFor(op: OperationDescriptor): ParseAs | 'void' { return 'auto'; } -/** Route the grouped args by the descriptor: path values, query object, body, extra headers, cookies. */ +/** + * The call's inputs in namespaced form, converting first on a flat-style client. An + * operation the generator marked `argsStyle: 'grouped'` is already namespaced — its names + * could not be merged, so its input type never offered the flat shape. + */ +function inputOf( + op: OperationDescriptor, + args: OperationArgs, + config: ClientConfig +): OperationArgs { + const merged = config.argsStyle === 'flat' && op.argsStyle !== 'grouped'; + return merged ? namespaceArgs(op, args) : args; +} + +/** Route the namespaced args to the request pieces. */ function splitArgs(op: OperationDescriptor, args: OperationArgs) { - const path: Record = {}; - const pathNames = new Set(); - for (const param of op.params ?? []) { - if (param.in === 'path') { - pathNames.add(param.name); - path[param.name] = args[param.name]; - } - } - // An unknown top-level key can only be a bug (usually a flat-style call shape passed - // to a grouped client: `{ limit: 10 }` instead of `{ params: { limit: 10 } }`). - // TypeScript catches it, but transpilers that skip type-checking would otherwise - // ship a request that silently drops the value — fail the call loudly instead. + // An unknown layer key can only be a bug (usually flat-style args on a namespaced + // client). TypeScript catches it, but a transpiler that skips type-checking would + // otherwise ship a request that silently drops the value — fail the call loudly. for (const key of Object.keys(args)) { - if (key === 'params' || key === 'body' || key === 'headers' || key === 'cookies') continue; - if (pathNames.has(key)) continue; - throw new TypeError( - `Unknown argument "${key}" for operation "${op.id}". Query parameters go under params: { … } and the request body under body; valid keys are params, body, headers, cookies` + - (pathNames.size > 0 ? `, and the path parameters (${[...pathNames].join(', ')}).` : '.') - ); + if (!LAYERS.includes(key)) { + throw new TypeError( + `Unknown argument "${key}" for operation "${op.id}". Inputs are grouped by layer: ${LAYERS.join(', ')}.` + ); + } } return { - path, - query: args.params, + path: args.path ?? {}, + query: args.query, body: args.body, headers: args.headers, cookies: args.cookies, @@ -1365,7 +1434,8 @@ function createClientCore< for (const [name, op] of Object.entries(operations)) { if (op.responseKind === 'sse') { - const method = (args: OperationArgs = {}, init: SseOptions = {}) => { + const method = (given: OperationArgs = {}, init: SseOptions = {}) => { + const args = inputOf(op, given, config); if (!caps.sse) { throw new Error(`SSE capability not wired: cannot stream operation "${op.id}"`); } @@ -1389,8 +1459,13 @@ function createClientCore< Object.defineProperty(method, 'operationId', { value: op.id }); client[name] = method; } else { - const method = (args: OperationArgs = {}, init: RequestOptions = {}) => + // `raw` takes namespaced args; `method` is the public entry that accepts whichever + // style the client was generated with. The iterators namespace once and then drive + // `raw`, so a flat call is never converted twice. + const raw = (args: OperationArgs = {}, init: RequestOptions = {}) => execute(config, op, args, init, caps); + const method = (args: OperationArgs = {}, init: RequestOptions = {}) => + raw(inputOf(op, args, config), init); Object.defineProperty(method, 'name', { value: name }); Object.defineProperty(method, 'operationId', { value: op.id }); const spec = op.pagination; @@ -1409,31 +1484,42 @@ function createClientCore< pages: (args?: OperationArgs, init?: RequestOptions) => paginateCapability(caps, op).pagesByLink( linkPageCall(config, op, caps), - args, + inputOf(op, args ?? {}, config), init ), items: (args?: OperationArgs, init?: RequestOptions) => paginateCapability(caps, op).itemsByLink( linkPageCall(config, op, caps), spec, - args, + inputOf(op, args ?? {}, config), init ), }) : Object.assign(method, { pages: (args?: OperationArgs, init?: RequestOptions) => - paginateCapability(caps, op).pages(pageCall(method, config), spec, args, init), + paginateCapability(caps, op).pages( + pageCall(raw, config), + spec, + inputOf(op, args ?? {}, config), + init + ), items: (args?: OperationArgs, init?: RequestOptions) => - paginateCapability(caps, op).items(pageCall(method, config), spec, args, init), + paginateCapability(caps, op).items( + pageCall(raw, config), + spec, + inputOf(op, args ?? {}, config), + init + ), }); } } // Core members are assigned AFTER the operation loop — they win over colliding op names. client.configure = (next: ClientConfig): void => { - // `errorMode` is fixed at generate time (it shapes the static types); flipping it at - // runtime would silently desync return shapes from `Client`, so it is ignored. - const { errorMode: _fixed, auth, ...rest } = next; + // `errorMode` and `argsStyle` are fixed at generate time (they shape the static types); + // flipping either at runtime would silently desync the calls from `Client`, so both + // are ignored here. + const { errorMode: _fixedMode, argsStyle: _fixedStyle, auth, ...rest } = next; Object.assign(config, rest); // `auth` merges into existing credentials (like the `auth.*` setters) rather than // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set @@ -1486,22 +1572,4 @@ export function createClient< export const client = createClient(OPERATIONS, { serverUrl: "https://api.cafe.redocly.com", clientHeader: "redocly-client-generator" }); export const { configure, use } = client; -export const listMenuItems = (params: { - /** - * Case-insensitive substring match on item names. - */ - search?: string; - /** - * Number of results per page. - * @minimum 1 - * @maximum 100 - */ - limit?: number; -} = {}, init?: I): Promise, I>> => client.listMenuItems({ params }, init) as Promise, I>>; -export const getMenuItemPhoto = (menuItemId: string, params: { - /** - * Photo size to retrieve. - */ - photoSize?: "thumbnail" | "medium" | "large"; -} = {}, init?: I): Promise, I>> => client.getMenuItemPhoto({ menuItemId, params }, init) as Promise, I>>; -export const registerOAuth2Client = (body: RegisterClientRequest, init?: I): Promise, I>> => client.registerOAuth2Client({ body }, init) as Promise, I>>; +export const { listMenuItems, getMenuItemPhoto, registerOAuth2Client } = client; diff --git a/tests/e2e/generate-client/examples/zero-install-quickstart/src/main.ts b/tests/e2e/generate-client/examples/zero-install-quickstart/src/main.ts index 54eb670ea4..e55fc036a2 100644 --- a/tests/e2e/generate-client/examples/zero-install-quickstart/src/main.ts +++ b/tests/e2e/generate-client/examples/zero-install-quickstart/src/main.ts @@ -5,14 +5,17 @@ // library to install or keep in sync. Import the generated functions and call. import { getMenuItemPhoto, listMenuItems } from './api/client.js'; -const menu = await listMenuItems({ limit: 3 }); +const menu = await listMenuItems({ query: { limit: 3 } }); for (const item of menu.items) { console.log(`${item.name} — $${(item.price / 100).toFixed(2)}`); } const [first] = menu.items; if (first) { - const photo = await getMenuItemPhoto(first.id, { photoSize: 'thumbnail' }); + const photo = await getMenuItemPhoto({ + path: { menuItemId: first.id }, + query: { photoSize: 'thumbnail' }, + }); console.log( photo instanceof Blob ? `${first.name} photo: ${photo.type}, ${photo.size} bytes` : photo ); diff --git a/tests/e2e/generate-client/examples/zod/README.md b/tests/e2e/generate-client/examples/zod/README.md index bbac00272b..8215a19cbc 100644 --- a/tests/e2e/generate-client/examples/zod/README.md +++ b/tests/e2e/generate-client/examples/zod/README.md @@ -1,6 +1,6 @@ # zod example -Generated TypeScript client plus **zod** schemas (`generators: ['sdk', 'zod']`). +Generated TypeScript client plus **zod** schemas (`generators: ['typescript', 'zod']`). The app turns on `zodValidation()` — request bodies and JSON responses are validated against the generated schemas on every call — and also uses a schema directly. diff --git a/tests/e2e/generate-client/examples/zod/redocly.yaml b/tests/e2e/generate-client/examples/zod/redocly.yaml index fe808914ae..5a816756d8 100644 --- a/tests/e2e/generate-client/examples/zod/redocly.yaml +++ b/tests/e2e/generate-client/examples/zod/redocly.yaml @@ -7,5 +7,5 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript - zod diff --git a/tests/e2e/generate-client/extension.test.ts b/tests/e2e/generate-client/extension.test.ts index 7a32f992b1..54bf15f55e 100644 --- a/tests/e2e/generate-client/extension.test.ts +++ b/tests/e2e/generate-client/extension.test.ts @@ -69,7 +69,7 @@ describe('extension contract — flat surface (configure)', () => { }); try { - await getPetById(1); + await getPetById({ path: { id: 1 } }); console.log(JSON.stringify({ threw: false })); } catch (e) { console.log(JSON.stringify({ threw: true, name: (e as Error).constructor.name, message: (e as Error).message })); diff --git a/tests/e2e/generate-client/fixtures/cli.yaml b/tests/e2e/generate-client/fixtures/cli.yaml new file mode 100644 index 0000000000..57dfa543ae --- /dev/null +++ b/tests/e2e/generate-client/fixtures/cli.yaml @@ -0,0 +1,93 @@ +openapi: 3.1.0 +info: + title: Cafe CLI API + version: 1.0.0 +servers: + - url: http://localhost:3108 +security: + - BearerAuth: [] +paths: + /orders: + get: + operationId: listOrders + summary: List orders, one cursor page at a time. + tags: [orders] + x-redoclyPagination: + style: cursor + cursorParam: cursor + nextCursor: /nextCursor + limitParam: limit + items: /orders + parameters: + - name: cursor + in: query + schema: { type: string } + - name: limit + in: query + schema: { type: integer } + - name: status + in: query + schema: { type: string, enum: [open, closed] } + responses: + '200': + description: One page of orders. + content: + application/json: + schema: + type: object + required: [orders] + properties: + orders: + type: array + items: { $ref: '#/components/schemas/Order' } + nextCursor: { type: string } + post: + operationId: createOrder + summary: Place an order. + tags: [orders] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + responses: + '201': + description: The created order. + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + /orders/{orderId}: + get: + operationId: getOrder + summary: One order by id. + tags: [orders] + parameters: + - name: orderId + in: path + required: true + schema: { type: string } + responses: + '200': + description: The order. + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + /ping: + get: + operationId: ping + responses: + '204': + description: Alive. +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + schemas: + Order: + type: object + required: [item, quantity] + properties: + id: { type: string } + item: { type: string } + quantity: { type: integer, minimum: 1 } diff --git a/tests/e2e/generate-client/fixtures/pagination.yaml b/tests/e2e/generate-client/fixtures/pagination.yaml index 04078216cc..817c33e56b 100644 --- a/tests/e2e/generate-client/fixtures/pagination.yaml +++ b/tests/e2e/generate-client/fixtures/pagination.yaml @@ -10,7 +10,7 @@ paths: operationId: listOrders summary: List orders, one cursor page at a time. # The extension arm: the pagination rule travels with the spec — no config needed. - x-redocly-pagination: + x-redoclyPagination: style: cursor cursorParam: cursor nextCursor: /nextCursor diff --git a/tests/e2e/generate-client/fixtures/repeated-params.yaml b/tests/e2e/generate-client/fixtures/repeated-params.yaml new file mode 100644 index 0000000000..e38698a0ec --- /dev/null +++ b/tests/e2e/generate-client/fixtures/repeated-params.yaml @@ -0,0 +1,74 @@ +openapi: 3.1.0 +info: + title: Repeated Params API + version: 1.0.0 + description: >- + Parameter names an SDK cannot take literally: the same name in two locations (which + OpenAPI permits), and names that clash with the arguments a generated method declares + itself (`body`, `headers`, `timeout`, `params`, `ctx`). Every generator must still emit + a module that parses, with the wire names untouched. +servers: + - url: https://api.example.com +paths: + /things/{id}: + get: + operationId: getThing + parameters: + - name: id + in: path + required: true + schema: { type: string } + - name: id + in: query + required: false + description: A filter that happens to share the path parameter's name. + schema: { type: integer } + responses: + '200': + description: One thing. + content: + application/json: + schema: { $ref: '#/components/schemas/Thing' } + /things/{body}/{ctx}: + post: + operationId: makeThing + parameters: + - name: body + in: path + required: true + schema: { type: string } + - name: ctx + in: path + required: true + schema: { type: string } + - name: timeout + in: query + required: false + schema: { type: string } + - name: headers + in: query + required: false + schema: { type: string } + - name: params + in: query + required: false + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/Thing' } + responses: + '200': + description: The created thing. + content: + application/json: + schema: { $ref: '#/components/schemas/Thing' } +components: + schemas: + Thing: + type: object + required: [id] + properties: + id: { type: string } + name: { type: string } diff --git a/tests/e2e/generate-client/fixtures/route-map-plugin.mjs b/tests/e2e/generate-client/fixtures/route-map-plugin.mjs index 937da80d82..8349875d80 100644 --- a/tests/e2e/generate-client/fixtures/route-map-plugin.mjs +++ b/tests/e2e/generate-client/fixtures/route-map-plugin.mjs @@ -2,8 +2,14 @@ // compiled CLI can import it under bare `node`. Emits a `.routes.ts` map of every operation. export default { name: 'route-map', - requires: ['sdk'], - run({ model, outputPath }) { + requires: ['typescript'], + // Declared options: the config block is validated against this before `run`. + options: { + type: 'object', + properties: { exportName: { type: 'string', default: 'routes' } }, + additionalProperties: false, + }, + run({ model, outputPath, options }) { const routes = model.services .flatMap((s) => s.operations) .map((op) => ` ${op.name}: '${op.method.toUpperCase()} ${op.path}',`) @@ -11,7 +17,7 @@ export default { return [ { path: outputPath.replace(/\.ts$/, '.routes.ts'), - content: `export const routes = {\n${routes}\n} as const;\n`, + content: `export const ${options.exportName} = {\n${routes}\n} as const;\n`, }, ]; }, diff --git a/tests/e2e/generate-client/generator-contract.test.ts b/tests/e2e/generate-client/generator-contract.test.ts index 1c7b982bcb..1a8ac056d9 100644 --- a/tests/e2e/generate-client/generator-contract.test.ts +++ b/tests/e2e/generate-client/generator-contract.test.ts @@ -5,7 +5,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { outdent } from 'outdent'; import { cliEntry, repoRoot, tscBin } from './helpers.js'; @@ -22,8 +22,26 @@ function run(args: string[]): { status: number | null; out: string } { return { status: res.status, out: `${res.stdout}\n${res.stderr}` }; } +// Every built-in must be discoverable from `--help`: an inline list that goes stale is +// what made four separate reports say the languages "aren't supported". +describe('generate-client --help', () => { + it('names every built-in generator', async () => { + // The metadata table is the registry the pipeline resolves against — the one list + // `--help` must not fall behind. + const { BUILTIN_META } = await import( + pathToFileURL(join(repoRoot, 'packages/client-generator/lib/generators/meta.js')).href + ); + // yargs wraps help text mid-token (`tanstack-query-v\nue`), so compare with the + // whitespace removed — a generator name never contains any. + const help = run(['--help']).out.replace(/\s+/g, ''); + for (const name of Object.keys(BUILTIN_META as Record)) { + expect(help, `--help does not mention the "${name}" generator`).toContain(name); + } + }, 60_000); +}); + describe('generate-client generator compatibility contract', () => { - it('rejects tanstack-query without sdk, naming the fix', () => { + it('pulls in the sdk a wrapper generator needs instead of failing', () => { const dir = mkdtempSync(join(tmpdir(), 'ots-contract-')); const { status, out } = run([ cafe, @@ -32,9 +50,10 @@ describe('generate-client generator compatibility contract', () => { '--generator', 'tanstack-query', ]); - expect(status).not.toBe(0); - expect(out).toMatch(/requires the "sdk" generator/); - expect(out).toMatch(/--generator sdk --generator tanstack-query/); + expect(status, out).toBe(0); + // The wrapper wraps the sdk's functions, so the sdk file has to exist. + expect(existsSync(join(dir, 'c.ts'))).toBe(true); + expect(existsSync(join(dir, 'c.tanstack.ts'))).toBe(true); rmSync(dir, { recursive: true, force: true }); }, 60_000); @@ -45,7 +64,7 @@ describe('generate-client generator compatibility contract', () => { '--output', join(dir, 'c.ts'), '--generator', - 'sdk', + 'typescript', '--generator', 'tanstack-query', '--error-mode', @@ -63,7 +82,7 @@ describe('generate-client generator compatibility contract', () => { '--output', join(dir, 'c.ts'), '--generator', - 'sdk', + 'typescript', '--generator', 'transformers', ]); @@ -91,7 +110,7 @@ describe('generate-client generator compatibility contract', () => { '--output', join(dir, 'c.ts'), '--generator', - 'sdk', + 'typescript', '--generator', 'tanstack-query', ]); @@ -134,7 +153,7 @@ describe('generate-client generator compatibility contract', () => { '--output', join(dir, 'c.ts'), '--generator', - 'sdk', + 'typescript', '--generator', 'tanstack-query', ]); diff --git a/tests/e2e/generate-client/go-consumer/.gitignore b/tests/e2e/generate-client/go-consumer/.gitignore new file mode 100644 index 0000000000..5d02d96ce0 --- /dev/null +++ b/tests/e2e/generate-client/go-consumer/.gitignore @@ -0,0 +1,2 @@ +client/ +smoke diff --git a/tests/e2e/generate-client/go-consumer/go.mod b/tests/e2e/generate-client/go-consumer/go.mod new file mode 100644 index 0000000000..60ca607154 --- /dev/null +++ b/tests/e2e/generate-client/go-consumer/go.mod @@ -0,0 +1,3 @@ +module smoke.test + +go 1.21 diff --git a/tests/e2e/generate-client/go-consumer/main.go b/tests/e2e/generate-client/go-consumer/main.go new file mode 100644 index 0000000000..64a5df4923 --- /dev/null +++ b/tests/e2e/generate-client/go-consumer/main.go @@ -0,0 +1,45 @@ +// Runtime smoke for the generated Go SDK, exercised against the same Node mock +// server the other consumers use. Built and run by go.test.ts with the server +// base URL as the only argument. +package main + +import ( + "context" + "errors" + "fmt" + "os" + + client "smoke.test/client" +) + +func main() { + ctx := context.Background() + api := client.New(client.Config{ServerURL: os.Args[1]}) + + // Typed call: the response decodes into the generated struct. + pet, err := api.GetPetById(ctx, 1) + if err != nil { + panic(err) + } + if pet.Name == "" { + panic("pet.Name should hydrate") + } + + // Collection + request body round-trips. + if _, err := api.ListPets(ctx, nil); err != nil { + panic(err) + } + if _, err := api.CreatePet(ctx, client.Pet{Name: "Smokey"}); err != nil { + panic(err) + } + + // A non-2xx returns the structured *APIError (a wrong base path 404s every route). + broken := client.New(client.Config{ServerURL: os.Args[1] + "/nowhere"}) + _, err = broken.GetPetById(ctx, 1) + var apiErr *client.APIError + if !errors.As(err, &apiErr) || apiErr.Status != 404 { + panic(fmt.Sprintf("expected a 404 APIError, got %v", err)) + } + + fmt.Println("GO_SMOKE_OK") +} diff --git a/tests/e2e/generate-client/go.test.ts b/tests/e2e/generate-client/go.test.ts new file mode 100644 index 0000000000..c67f86f381 --- /dev/null +++ b/tests/e2e/generate-client/go.test.ts @@ -0,0 +1,110 @@ +import { spawnSync, type ChildProcess } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { generate, killServer, startServer } from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixture = join(__dirname, 'fixtures/base.yaml'); +const consumerDir = join(__dirname, 'go-consumer'); +const generatedFile = join(consumerDir, 'client/client.go'); + +const SERVER_PORT = 3107; +const SERVER_BASE = `http://127.0.0.1:${SERVER_PORT}`; + +const hasGo = spawnSync('go', ['version']).status === 0; + +describe('generate-client go generator (end-to-end)', () => { + afterAll(() => { + rmSync(join(consumerDir, 'client'), { recursive: true, force: true }); + rmSync(join(consumerDir, 'smoke'), { force: true }); + rmSync(join(consumerDir, 'renamed-package'), { recursive: true, force: true }); + }); + + it('generates a self-contained client.go from the CLI', () => { + generate(fixture, join(consumerDir, 'client/client.ts'), ['--generator', 'go']); + expect(existsSync(generatedFile)).toBe(true); + }); + + it('--go-package sets the package clause', () => { + const target = join(consumerDir, 'renamed-package'); + generate(fixture, join(target, 'client.ts'), ['--generator', 'go', '--go-package', 'rebilly']); + expect(readFileSync(join(target, 'client.go'), 'utf-8')).toContain('\npackage rebilly\n'); + }); + + it.skipIf(!hasGo)( + 'the generated client compiles (go build)', + () => { + const result = spawnSync('go', ['build', '-o', 'smoke', '.'], { + cwd: consumerDir, + encoding: 'utf-8', + }); + expect(result.status, result.stderr).toBe(0); + }, + // The first build on a cold CI cache compiles the stdlib and takes well over + // the 5s default. + 180_000 + ); + + it.skipIf(!hasGo)( + 'the compiled smoke runs real HTTP: hydration, bodies, APIError', + async () => { + let serverProcess: ChildProcess | undefined; + try { + serverProcess = await startServer( + join(__dirname, 'base-consumer/server.ts'), + join(__dirname, 'base-consumer'), + { BASE_SERVER_PORT: String(SERVER_PORT) }, + SERVER_BASE, + 'go-smoke-server' + ); + const result = spawnSync(join(consumerDir, 'smoke'), [SERVER_BASE], { + encoding: 'utf-8', + }); + expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain('GO_SMOKE_OK'); + } finally { + if (serverProcess) await killServer(serverProcess); + } + }, + 60_000 + ); +}); + +describe('generate-client go generator, parameter names an SDK cannot take literally', () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'go-repeated-')); + generate(join(__dirname, 'fixtures/repeated-params.yaml'), join(dir, 'client.ts'), [ + '--generator', + 'go', + ]); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('renames a parameter that clashes with one of the method arguments', () => { + const source = readFileSync(join(dir, 'client.go'), 'utf-8'); + // Query params live in their own struct, so `id` needs no rename here… + expect(source).toContain('func (c *Client) GetThing(ctx context.Context, id string'); + // …but a path parameter named after an argument the method declares itself does. + expect(source).toContain( + 'func (c *Client) MakeThing(ctx context.Context, body2 string, ctx2 string, body Thing' + ); + }); + + it.skipIf(!hasGo)( + 'the generated client compiles (go build)', + () => { + writeFileSync(join(dir, 'go.mod'), 'module repeatedparams\n\ngo 1.21\n', 'utf-8'); + const result = spawnSync('go', ['build', './...'], { cwd: dir, encoding: 'utf-8' }); + expect(result.status, result.stderr).toBe(0); + }, + 180_000 + ); +}); diff --git a/tests/e2e/generate-client/helpers.ts b/tests/e2e/generate-client/helpers.ts index 00ac8fd48b..b2b5e270b5 100644 --- a/tests/e2e/generate-client/helpers.ts +++ b/tests/e2e/generate-client/helpers.ts @@ -114,6 +114,25 @@ export async function waitForServerReady( ); } +/** + * Read a test server's request log. The fetch itself retries: a loaded machine (the + * generator suite compiles Go, PHP, and TypeScript in parallel) occasionally resets a + * connection to the local server, which says nothing about the client under test. + */ +export async function serverLog>>(baseUrl: string): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < 3; attempt++) { + try { + const response = await fetch(`${baseUrl}/__test__/log`); + return (await response.json()) as T; + } catch (error) { + lastError = error; + await new Promise((resolveFn) => setTimeout(resolveFn, 100)); + } + } + throw lastError; +} + export function killServer(server: ChildProcess): Promise { return new Promise((resolveFn) => { if (!server.pid || server.exitCode !== null) { diff --git a/tests/e2e/generate-client/identifier-injection.test.ts b/tests/e2e/generate-client/identifier-injection.test.ts index af9abed7e4..904c3422e8 100644 --- a/tests/e2e/generate-client/identifier-injection.test.ts +++ b/tests/e2e/generate-client/identifier-injection.test.ts @@ -64,16 +64,20 @@ describe('generate-client identifier / comment injection', () => { ); expect(res.status, res.stderr).toBe(0); // The unsafe operationId is reported and rewritten, not silently accepted. - expect(res.stderr).toMatch(/is not a valid TypeScript identifier/); + expect(res.stderr).toMatch(/is not a usable identifier/); const src = readFileSync(entry, 'utf-8'); // No live comment-breakout: the payload's `*/` is neutralized to `*\/`. expect(src).not.toMatch(/\*\/\s*;globalThis/); // No payload survives as a top-level statement (only inside identifiers/comments). expect(src).not.toMatch(/^\s*globalThis\.PWNED/m); - // The operation name became a single valid identifier (no parens/spaces/semicolons) - // in the flat call sugar. - expect(src).toMatch(/export const [A-Za-z_$][A-Za-z0-9_$]* = \([^)]*\) => client\./); + // The operation name became a single valid identifier (no parens, spaces, or + // semicolons), and it is exported by destructuring the client under that same name. + const bindings = src.match(/export const \{ ([^}]*) \} = client;\s*$/m); + expect(bindings, 'no operation bindings found in the generated client').not.toBeNull(); + for (const name of bindings![1].split(', ')) { + expect(name).toMatch(/^[A-Za-z_$][A-Za-z0-9_$]*$/); + } // Strongest proof: the whole file type-checks. Injected statements would not. const tsc = spawnSync( diff --git a/tests/e2e/generate-client/large-descriptions.test.ts b/tests/e2e/generate-client/large-descriptions.test.ts new file mode 100644 index 0000000000..89083cf2d1 --- /dev/null +++ b/tests/e2e/generate-client/large-descriptions.test.ts @@ -0,0 +1,151 @@ +// Every generator's output held to a compile/import bar over two large real-world +// descriptions: the vendored one at tests/smoke/rebilly (638 operations, allOf-heavy — +// shook out the allOf pagination fix and the Go `3ds` field-export bug) and GitHub's +// REST description (~1000 operations, downloaded at a pinned SHA — shook out the +// strict-mode reserved-word and +1/-1 naming bugs). The heaviest e2e file by far; +// CI spreads it across shards like any other suite. + +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { generate, repoRoot, strictTypecheck } from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TIMEOUT = 300_000; + +/** Pinned commit of github/rest-api-description; bump deliberately. */ +const GITHUB_DESCRIPTION_SHA = '5e28810649ba41b5483753ba74f976f83856a504'; + +const cacheDir = join(__dirname, '.cache'); + +/** Download `api.github.com.json` at the pinned SHA once; later runs hit the cache. */ +async function fetchGithubDescription(): Promise { + const cached = join(cacheDir, `api.github.com-${GITHUB_DESCRIPTION_SHA.slice(0, 12)}.json`); + if (existsSync(cached)) return cached; + const url = `https://raw.githubusercontent.com/github/rest-api-description/${GITHUB_DESCRIPTION_SHA}/descriptions/api.github.com/api.github.com.json`; + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to download ${url}: ${response.status}`); + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(cached, Buffer.from(await response.arrayBuffer())); + return cached; +} + +const hasPhp = spawnSync('php', ['--version']).status === 0; +const hasPython = spawnSync('python3', ['--version']).status === 0; +const hasHttpx = hasPython && spawnSync('python3', ['-c', 'import httpx']).status === 0; +const hasGo = spawnSync('go', ['version']).status === 0; + +/** Generate with `--generator ` (repeatable) into a fresh temp dir; returns the dir. */ +function generateWith(generator: string | string[], description: string): string { + const generators = Array.isArray(generator) ? generator : [generator]; + const dir = mkdtempSync(join(tmpdir(), `large-desc-${generators.join('-')}-`)); + generate( + description, + join(dir, 'client.ts'), + generators.flatMap((name) => ['--generator', name]) + ); + return dir; +} + +/** TS bar: the generated client passes a strict `tsc --noEmit`. */ +function typescriptBar(description: string): void { + const dir = generateWith('typescript', description); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); + strictTypecheck(dir); +} + +/** + * CLI bar: the generated `.cli.ts` passes a strict, Node-typed `tsc --noEmit`. + * Selecting `cli` also emits the zod module it validates with, so the resolver needs a + * path to `zod` — taken from the repo, like `@types/node` below. + */ +function cliBar(description: string): void { + const dir = generateWith(['typescript', 'cli'], description); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); + // The temp dir sits outside the repo, so node resolution finds nothing: borrow the + // repo's node_modules for `zod` (the CLI's validation) and `@types/node`. + symlinkSync(join(repoRoot, 'node_modules'), join(dir, 'node_modules'), 'dir'); + writeFileSync( + join(dir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + module: 'nodenext', + moduleResolution: 'nodenext', + target: 'es2022', + lib: ['ES2022', 'DOM'], + strict: true, + noEmit: true, + skipLibCheck: true, + types: ['node'], + typeRoots: [join(repoRoot, 'node_modules/@types')], + }, + include: ['**/*.ts'], + }), + 'utf-8' + ); + const tsc = spawnSync(join(repoRoot, 'node_modules/.bin/tsc'), ['-p', dir], { + encoding: 'utf-8', + }); + expect(tsc.status, `${tsc.stdout}\n${tsc.stderr}`).toBe(0); +} + +/** + * Python bar: `import client` (executes every dataclass declaration — catches + * duplicate fields and bad defaults); syntax-only `py_compile` when httpx is absent. + */ +function pythonBar(description: string): void { + const dir = generateWith('python', description); + const check = hasHttpx + ? spawnSync('python3', ['-c', 'import client'], { cwd: dir, encoding: 'utf-8' }) + : spawnSync('python3', ['-m', 'py_compile', 'client.py'], { cwd: dir, encoding: 'utf-8' }); + expect(check.status, check.stderr).toBe(0); +} + +/** PHP bar: the generated `.php` parses (`php -l`) and declares (`require`). */ +function phpBar(description: string): void { + const dir = generateWith('php', description); + const lint = spawnSync('php', ['-l', 'client.php'], { cwd: dir, encoding: 'utf-8' }); + expect(lint.status, `${lint.stdout}\n${lint.stderr}`).toBe(0); + const declare = spawnSync('php', ['-r', "require 'client.php'; echo 'DECLARED';"], { + cwd: dir, + encoding: 'utf-8', + }); + expect(declare.status, `${declare.stdout}\n${declare.stderr}`).toBe(0); +} + +/** Go bar: `go build` + `go vet` (vet catches json tags on unexported fields). */ +function goBar(description: string): void { + const dir = generateWith('go', description); + writeFileSync(join(dir, 'go.mod'), 'module largedesc.test\n\ngo 1.21\n', 'utf-8'); + const build = spawnSync('go', ['build', './...'], { cwd: dir, encoding: 'utf-8' }); + expect(build.status, build.stderr).toBe(0); + const vet = spawnSync('go', ['vet', './...'], { cwd: dir, encoding: 'utf-8' }); + expect(vet.status, vet.stderr).toBe(0); +} + +const rebilly = join(__dirname, '../../smoke/rebilly/rebilly-description.yaml'); + +describe('rebilly description', () => { + it('sdk (TypeScript) passes strict tsc', () => typescriptBar(rebilly), TIMEOUT); + it('cli passes strict Node-typed tsc', () => cliBar(rebilly), TIMEOUT); + it.skipIf(!hasPython)('python imports cleanly', () => pythonBar(rebilly), TIMEOUT); + it.skipIf(!hasGo)('go builds and vets cleanly', () => goBar(rebilly), TIMEOUT); + it.skipIf(!hasPhp)('php parses and declares cleanly', () => phpBar(rebilly), TIMEOUT); +}); + +describe('github REST description', () => { + let github: string; + + beforeAll(async () => { + github = await fetchGithubDescription(); + }, TIMEOUT); + + it('sdk (TypeScript) passes strict tsc', () => typescriptBar(github), TIMEOUT); + it('cli passes strict Node-typed tsc', () => cliBar(github), TIMEOUT); + it.skipIf(!hasPython)('python imports cleanly', () => pythonBar(github), TIMEOUT); + it.skipIf(!hasGo)('go builds and vets cleanly', () => goBar(github), TIMEOUT); + it.skipIf(!hasPhp)('php parses and declares cleanly', () => phpBar(github), TIMEOUT); +}); diff --git a/tests/e2e/generate-client/middleware.test.ts b/tests/e2e/generate-client/middleware.test.ts index efcf88d897..a89c081d39 100644 --- a/tests/e2e/generate-client/middleware.test.ts +++ b/tests/e2e/generate-client/middleware.test.ts @@ -69,7 +69,7 @@ describe('middleware — flat surface (use)', () => { { onError: (e) => new Error('second:' + e.message) }, ); try { - await getPetById(1); + await getPetById({ path: { id: 1 } }); console.log(JSON.stringify({ threw: false })); } catch (e) { console.log(JSON.stringify({ threw: true, message: (e as Error).message })); @@ -128,7 +128,7 @@ describe('middleware — flat surface (use)', () => { let op: unknown; configure({ fetch: (async () => ${OK}) as unknown as typeof fetch }); use({ onRequest: (ctx) => { op = ctx.operation; } }); - await createPet({ name: 'Rex' }); + await createPet({ body: { name: 'Rex' } }); console.log(JSON.stringify({ op })); ` ) as { op: { id: string; path: string; tags: string[] } }; @@ -149,7 +149,7 @@ describe('middleware — flat surface (use)', () => { fetch: (async (_url: string, init: RequestInit) => { sent = init.body as string; return ${OK}; }) as unknown as typeof fetch, }); use({ onRequest: (ctx) => { (ctx.body as { name: string }).name = 'Mutated'; } }); - await createPet({ name: 'Rex' }); + await createPet({ body: { name: 'Rex' } }); console.log(JSON.stringify({ sent })); ` ) as { sent: string }; @@ -210,7 +210,7 @@ describe('middleware — result error mode', () => { onResponse: () => { ran.push('res'); }, onError: () => { ran.push('err'); return new Error('should-not-run'); }, }); - const r = await getPetById(1) as { error: unknown; data: unknown }; + const r = await getPetById({ path: { id: 1 } }) as { error: unknown; data: unknown }; console.log(JSON.stringify({ ran, hasError: r.error !== undefined, hasData: r.data !== undefined })); ` ) as { ran: string[]; hasError: boolean; hasData: boolean }; diff --git a/tests/e2e/generate-client/mock.test.ts b/tests/e2e/generate-client/mock.test.ts index e575d12ff5..28c37caddc 100644 --- a/tests/e2e/generate-client/mock.test.ts +++ b/tests/e2e/generate-client/mock.test.ts @@ -1,5 +1,5 @@ /** - * Behavioral e2e for the `mock` generator. We generate `sdk,mock` into a temp dir, + * Behavioral e2e for the `mock` generator. We generate `typescript,mock` into a temp dir, * then run a real consumer (via tsx) that installs the emitted MSW handlers into * `setupServer` and calls a generated client operation whose native `fetch` MSW * intercepts. With `onUnhandledRequest: 'error'`, a resolved call proves interception @@ -29,7 +29,7 @@ describe('mock generator — generated client through MSW', () => { // relative to the importing file, so the temp dir must live inside the repo // tree to walk up to the root node_modules — `os.tmpdir()` would not resolve it. dir = mkdtempSync(join(__dirname, 'mock-consumer-')); - generateInto(dir, fixture, ['--generator', 'sdk', '--generator', 'mock']); + generateInto(dir, fixture, ['--generator', 'typescript', '--generator', 'mock']); }, 60_000); afterAll(() => { if (dir && existsSync(dir)) rmSync(dir, { recursive: true, force: true }); @@ -51,7 +51,7 @@ describe('mock generator — generated client through MSW', () => { server.listen({ onUnhandledRequest: 'error' }); configure({ serverUrl: 'https://api.example.com' }); try { - const pet = await getPetById(1); + const pet = await getPetById({ path: { id: 1 } }); process.stdout.write(JSON.stringify({ ok: pet !== undefined, id: pet.id, name: pet.name })); } finally { server.close(); @@ -102,7 +102,7 @@ describe('mock generator — mock + transformers + --date-type Date compile toge // so the mock sampler must bake `new Date(...)` to type-check (BUG 1 regression). generateInto(dir, dateFixture, [ '--generator', - 'sdk', + 'typescript', '--generator', 'mock', '--generator', @@ -148,7 +148,7 @@ describe('mock generator — faker mode strict-tsc-checks against real @faker-js dir = mkdtempSync(join(__dirname, 'mock-faker-')); generateInto(dir, fixture, [ '--generator', - 'sdk', + 'typescript', '--generator', 'mock', '--mock-data', diff --git a/tests/e2e/generate-client/multipart.test.ts b/tests/e2e/generate-client/multipart.test.ts index 1dd24bbcee..3e2a511327 100644 --- a/tests/e2e/generate-client/multipart.test.ts +++ b/tests/e2e/generate-client/multipart.test.ts @@ -66,7 +66,7 @@ describe('generate-client typed multipart body (#5)', () => { }); const file = new Blob(['hello'], { type: 'text/plain' }); - await upload({ file, orgId: 'org_1', tags: ['a', 'b'], meta: { k: 'v' } }); + await upload({ body: { file, orgId: 'org_1', tags: ['a', 'b'], meta: { k: 'v' } } }); const fd = body as FormData; console.log(JSON.stringify({ @@ -112,7 +112,7 @@ describe('generate-client typed multipart body (#5)', () => { use({ onRequest: (ctx) => { (ctx.body as { orgId: string }).orgId = 'mutated'; } }); const file = new Blob(['hi'], { type: 'text/plain' }); - await upload({ file, orgId: 'org_1' }); + await upload({ body: { file, orgId: 'org_1' } }); const fd = body as FormData; console.log(JSON.stringify({ isFormData: fd instanceof FormData, orgId: fd.get('orgId') })); diff --git a/tests/e2e/generate-client/package-mode.test.ts b/tests/e2e/generate-client/package-mode.test.ts index 3de4b3a1d3..f8a6605be2 100644 --- a/tests/e2e/generate-client/package-mode.test.ts +++ b/tests/e2e/generate-client/package-mode.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { cliEntry, generate, killServer, repoRoot, startServer } from './helpers.js'; +import { cliEntry, generate, killServer, repoRoot, startServer, serverLog } from './helpers.js'; // The `runtime: package` output: instead of inlining the runtime, the generated // client imports `createClient` from `@redocly/client-generator` (resolved through @@ -115,12 +115,10 @@ describe('generate-client package-runtime consumer', () => { 'streamEvents', ]); - const logResponse = await fetch(`${SERVER_BASE}/__test__/log`); - const log = (await logResponse.json()) as Array<{ - method: string; - url: string; - auth: string | null; - }>; + const log = await serverLog>( + SERVER_BASE + ); + // Wire-name path substitution + query serialization + injected bearer. expect(log).toContainEqual({ method: 'GET', @@ -159,7 +157,7 @@ describe('generate-client package-runtime consumer', () => { api: fixture, output: tanstackEntry, runtime: 'package', - generators: ['sdk', 'tanstack-query'], + generators: ['typescript', 'tanstack-query'], }); expect(existsSync(tanstackEntry)).toBe(true); expect(readFileSync(tanstackEntry, 'utf-8')).toContain("from '@redocly/client-generator'"); @@ -187,7 +185,7 @@ describe('generate-client package-runtime consumer', () => { output, runtime: 'package', argsStyle: 'grouped', - generators: ['sdk', 'tanstack-query'], + generators: ['typescript', 'tanstack-query'], }); writeFileSync( join(dir, 'tsconfig.json'), diff --git a/tests/e2e/generate-client/package-runtime-consumer/index.ts b/tests/e2e/generate-client/package-runtime-consumer/index.ts index 68ec914434..24cc14f26b 100644 --- a/tests/e2e/generate-client/package-runtime-consumer/index.ts +++ b/tests/e2e/generate-client/package-runtime-consumer/index.ts @@ -1,4 +1,4 @@ -import { client, configure_2, createOrder, getOrder, setBearer, streamEvents, use } from './api.js'; +import { client, configure_2, createOrder, getOrder, streamEvents, use } from './api.js'; async function main(): Promise { const middlewareIds: string[] = []; @@ -7,13 +7,13 @@ async function main(): Promise { middlewareIds.push(ctx.operation.id); }, }); - setBearer('test-token'); + client.auth.bearer('test-token'); // Flat sugar: positional path value forwarded under the wire name `order-id`. - const order = await getOrder('o-1', { expand: 'items' }); + const order = await getOrder({ path: { 'order-id': 'o-1' }, query: { expand: 'items' } }); // Grouped instance call: the caller uses the wire-name key directly. - const grouped = await client.getOrder({ 'order-id': 'o-2' }); - const created = await createOrder({ status: 'open' }); + const grouped = await client.getOrder({ path: { 'order-id': 'o-2' } }); + const created = await createOrder({ body: { status: 'open' } }); // The op whose id collides with the reserved `configure` member — renamed sugar, // while middleware still sees the SPEC operationId. const collided = await configure_2(); diff --git a/tests/e2e/generate-client/pagination-consumer/index-abort.ts b/tests/e2e/generate-client/pagination-consumer/index-abort.ts index 36ecc4314c..9b2c51f770 100644 --- a/tests/e2e/generate-client/pagination-consumer/index-abort.ts +++ b/tests/e2e/generate-client/pagination-consumer/index-abort.ts @@ -10,7 +10,7 @@ async function main(): Promise { try { for await (const order of listOrders.items( - { params: { limit: 2 } }, + { query: { limit: 2 } }, { signal: controller.signal } )) { void order; diff --git a/tests/e2e/generate-client/pagination-consumer/index-offset.ts b/tests/e2e/generate-client/pagination-consumer/index-offset.ts index 6a87be35e6..3fb9699b11 100644 --- a/tests/e2e/generate-client/pagination-consumer/index-offset.ts +++ b/tests/e2e/generate-client/pagination-consumer/index-offset.ts @@ -5,17 +5,17 @@ import { listMenuItems, OPERATIONS } from './api-offset.js'; // each page's item count until an empty page arrives. async function main(): Promise { const names: string[] = []; - for await (const item of listMenuItems.items({ params: { limit: 2 } })) { + for await (const item of listMenuItems.items({ query: { limit: 2 } })) { names.push(item.name); // compile-time: `item` is `MenuItem` } // The trailing empty page IS yielded (every page arrives before the stop check). const pageSizes: number[] = []; - for await (const page of listMenuItems.pages({ params: { limit: 2 } })) { + for await (const page of listMenuItems.pages({ query: { limit: 2 } })) { pageSizes.push(page.items.length); } - // Precedence, pinned at compile time: the spec's `x-redocly-pagination` (cursor) beats the + // Precedence, pinned at compile time: the spec's `x-redoclyPagination` (cursor) beats the // offset convention on `listOrders` — its descriptor keeps the extension's rule. const listOrdersStyle: 'cursor' = OPERATIONS.listOrders.pagination.style; diff --git a/tests/e2e/generate-client/pagination-consumer/index-package.ts b/tests/e2e/generate-client/pagination-consumer/index-package.ts index a73e8650f8..bee2fe501f 100644 --- a/tests/e2e/generate-client/pagination-consumer/index-package.ts +++ b/tests/e2e/generate-client/pagination-consumer/index-package.ts @@ -5,7 +5,7 @@ import { listOrders } from './api-package.js'; // package — one full `.items()` walk proves the capability is wired there too. async function main(): Promise { const ids: string[] = []; - for await (const order of listOrders.items({ params: { limit: 2 } })) { + for await (const order of listOrders.items({ query: { limit: 2 } })) { ids.push(order.id); } diff --git a/tests/e2e/generate-client/pagination-consumer/index.ts b/tests/e2e/generate-client/pagination-consumer/index.ts index 6931903526..fdb233bee2 100644 --- a/tests/e2e/generate-client/pagination-consumer/index.ts +++ b/tests/e2e/generate-client/pagination-consumer/index.ts @@ -1,32 +1,32 @@ import { listOrders } from './api.js'; -// The extension arm: `x-redocly-pagination` in the spec (no config) drives `listOrders`. +// The extension arm: `x-redoclyPagination` in the spec (no config) drives `listOrders`. // Exercises `.items()` across three cursor pages, `.pages()` page-level access, and // resume from a caller-provided cursor — while the caller's args are never mutated. async function main(): Promise { - // `.items()`: the flat sugar preserves the method-attached iterators; every request - // forwards the caller's `limit` alongside the advancing cursor. - const firstArgs = { params: { limit: 2 } }; + // `.items()` takes the same input as the call itself, because it IS the same function's + // member. Every request forwards the caller's `limit` alongside the advancing cursor. + const firstArgs = { query: { limit: 2 } }; const ids: string[] = []; for await (const order of listOrders.items(firstArgs)) { ids.push(order.id); // compile-time: `order` is `Order` } - // The iterator clones params per request — the cursor never leaks into caller args. - const firstCursorLeaked = 'cursor' in firstArgs.params; + // The iterator clones the query bag per request — the cursor never leaks into caller args. + const firstCursorLeaked = 'cursor' in firstArgs.query; // `.pages()`: whole pages, typed as the raw response — sizes pin the 2+2+1 layout. const pageSizes: number[] = []; - for await (const page of listOrders.pages({ params: { limit: 2 } })) { + for await (const page of listOrders.pages({ query: { limit: 2 } })) { pageSizes.push(page.orders.length); } // Resume: a caller-provided initial cursor starts iteration at that page. - const resumeArgs = { params: { cursor: 'c2', limit: 2 } }; + const resumeArgs = { query: { cursor: 'c2', limit: 2 } }; const resumedIds: string[] = []; for await (const order of listOrders.items(resumeArgs)) { resumedIds.push(order.id); } - const resumeCursorAfter = resumeArgs.params.cursor; + const resumeCursorAfter = resumeArgs.query.cursor; process.stdout.write( JSON.stringify({ ids, firstCursorLeaked, pageSizes, resumedIds, resumeCursorAfter }) + '\n' diff --git a/tests/e2e/generate-client/pagination.test.ts b/tests/e2e/generate-client/pagination.test.ts index c00922da45..267ee28248 100644 --- a/tests/e2e/generate-client/pagination.test.ts +++ b/tests/e2e/generate-client/pagination.test.ts @@ -3,9 +3,9 @@ import { existsSync, readFileSync, rmSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { killServer, repoRoot, startServer } from './helpers.js'; +import { killServer, repoRoot, startServer, serverLog } from './helpers.js'; -// Auto-pagination end to end, over a live server: the `x-redocly-pagination` extension arm +// Auto-pagination end to end, over a live server: the `x-redoclyPagination` extension arm // (cursor style — three pages, resume, abort) generated with NO config, the // config-convention arm (offset style, applied only where it structurally fits), and a // package-mode arm proving `.pages()`/`.items()` ship from the installed runtime. @@ -39,9 +39,8 @@ async function resetLog(): Promise { expect(response.ok).toBe(true); } -async function fetchLog(): Promise> { - const response = await fetch(`${SERVER_BASE}/__test__/log`); - return (await response.json()) as Array<{ method: string; url: string }>; +function fetchLog(): Promise> { + return serverLog>(SERVER_BASE); } function runConsumer(script: string): { stdout: string } { @@ -79,7 +78,7 @@ describe('generate-client pagination consumer', () => { test('generate all three arms and assert the emitted pagination surface', async () => { const generateClient = await loadGenerateClient(); - // Extension arm: NO pagination config — `x-redocly-pagination` alone drives `listOrders`. + // Extension arm: NO pagination config — `x-redoclyPagination` alone drives `listOrders`. await generateClient({ api: fixture, output: apiFile }); // Convention arm: an offset rule applied to every operation it structurally fits. await generateClient({ @@ -105,12 +104,10 @@ describe('generate-client pagination consumer', () => { expect(api).toContain( 'getOrder: { id: "getOrder", method: "GET", path: "/orders/{orderId}", params: [{ name: "orderId", in: "path" }] }' ); - // …and the flat sugar preserves `.pages`/`.items` via Object.assign. - expect(api).toContain( - 'export const listOrders = Object.assign((params: {' - ); - expect(api).toContain('{ pages: client.listOrders.pages, items: client.listOrders.items });'); - expect(api).not.toContain('client.listMenuItems.pages'); + // …and the exported name is the client method itself, so `.pages`/`.items` ride along + // with the same input shape as the call. No wrapper, no second argument shape. + expect(api).toContain('export const { listOrders, listMenuItems, getOrder } = client;'); + expect(api).not.toContain('export const listOrders = Object.assign'); // Inline mode embeds paginate.ts (the infinite-loop guard is its fingerprint). expect(api).toContain('// ─── Embedded runtime'); expect(api).toContain('Pagination did not advance'); @@ -121,9 +118,7 @@ describe('generate-client pagination consumer', () => { 'listMenuItems: { id: "listMenuItems", method: "GET", path: "/menu", params: [{ name: "offset", in: "query" }, { name: "limit", in: "query" }], pagination: { style: "offset", param: "offset", limitParam: "limit", items: "/items" } }' ); expect(offset).toContain('item: MenuItem;'); - expect(offset).toContain( - '{ pages: client.listMenuItems.pages, items: client.listMenuItems.items });' - ); + expect(offset).toContain('export const { listOrders, listMenuItems, getOrder } = client;'); // …precedence keeps the extension's cursor rule on listOrders (not the convention)… expect(offset).toContain( 'pagination: { style: "cursor", param: "cursor", limitParam: "limit", nextCursor: "/nextCursor", items: "/orders" }' @@ -140,7 +135,7 @@ describe('generate-client pagination consumer', () => { expect(pkg).toContain( 'pagination: { style: "cursor", param: "cursor", limitParam: "limit", nextCursor: "/nextCursor", items: "/orders" }' ); - expect(pkg).toContain('{ pages: client.listOrders.pages, items: client.listOrders.items });'); + expect(pkg).toContain('export const { listOrders, listMenuItems, getOrder } = client;'); }, 60_000); test('typecheck gate: all three generated clients + consumer scripts, strict', () => { diff --git a/tests/e2e/generate-client/parse-as.test.ts b/tests/e2e/generate-client/parse-as.test.ts index dddaa793bd..74f8d62a73 100644 --- a/tests/e2e/generate-client/parse-as.test.ts +++ b/tests/e2e/generate-client/parse-as.test.ts @@ -45,15 +45,15 @@ describe('generate-client parseAs', () => { "import { getGiftcardsCardId } from './client.js';", '', 'export async function streamUsage() {', - " return getGiftcardsCardId({ parseAs: 'stream' });", + " return getGiftcardsCardId({ path: { cardId: 'gc_1' } }, { parseAs: 'stream' });", '}', '', 'export async function textUsage() {', - " return getGiftcardsCardId({ parseAs: 'text' });", + " return getGiftcardsCardId({ path: { cardId: 'gc_1' } }, { parseAs: 'text' });", '}', '', '// @ts-expect-error — parseAs is a closed union; bogus kinds are rejected.', - "export const bogus = getGiftcardsCardId({ parseAs: 'xml' });", + "export const bogus = getGiftcardsCardId({ path: { cardId: 'gc_1' } }, { parseAs: 'xml' });", '', ].join('\n'), 'utf-8' diff --git a/tests/e2e/generate-client/path-param-idents.test.ts b/tests/e2e/generate-client/path-param-idents.test.ts index 20ccae5e9f..4f82c0eb75 100644 --- a/tests/e2e/generate-client/path-param-idents.test.ts +++ b/tests/e2e/generate-client/path-param-idents.test.ts @@ -93,20 +93,16 @@ describe('non-identifier path parameters', () => { if (dir && existsSync(dir)) rmSync(dir, { recursive: true, force: true }); }); - test('emits safe argument names routed back under the wire name', () => { + test('keys the path layer by the wire name, quoting what needs it', () => { const client = readFileSync(join(dir, 'client.ts'), 'utf-8'); - // `widget-id` → safe `widget_id` argument, routed under the quoted wire key. - expect(client).toContain( - 'export const getWidget = (widget_id: string, init?: I)' - ); - expect(client).toContain('client.getWidget({ "widget-id": widget_id }, init)'); + // The wire name IS the key, so no binding identifier is derived and nothing to remap. + expect(client).toContain('export type GetWidgetPath = {\n "widget-id": string;\n};'); // The descriptor keeps the WIRE name for URL substitution. expect(client).toContain('params: [{ name: "widget-id", in: "path" }]'); - // reserved word `new` → `_new` argument, routed under the `new` key. - expect(client).toContain( - 'export const getItem = (_new: string, init?: I)' - ); - expect(client).toContain('client.getItem({ new: _new }, init)'); + // A reserved word is a fine object key, quoted or not. + // A reserved word is quoted as a key, which is what makes it usable as one. + expect(client).toContain('export type GetItemPath = {\n "new": string;\n};'); + expect(client).not.toContain('_new'); }); test('the generated client type-checks under strict mode', () => { @@ -130,8 +126,8 @@ describe('non-identifier path parameters', () => { }) as unknown as typeof fetch, }); - await getWidget('abc'); - await getItem('xyz'); + await getWidget({ path: { 'widget-id': 'abc' } }); + await getItem({ path: { new: 'xyz' } }); console.log(JSON.stringify(urls)); `; const urls = runConsumer(dir, consumer) as string[]; diff --git a/tests/e2e/generate-client/php-consumer/.gitignore b/tests/e2e/generate-client/php-consumer/.gitignore new file mode 100644 index 0000000000..684bec4c9f --- /dev/null +++ b/tests/e2e/generate-client/php-consumer/.gitignore @@ -0,0 +1 @@ +client/ diff --git a/tests/e2e/generate-client/php-consumer/smoke.php b/tests/e2e/generate-client/php-consumer/smoke.php new file mode 100644 index 0000000000..3abfdd2433 --- /dev/null +++ b/tests/e2e/generate-client/php-consumer/smoke.php @@ -0,0 +1,40 @@ +getPetById(1); +if (!($pet instanceof Pet) || $pet->name === '') { + fwrite(STDERR, "pet should hydrate into the Pet class\n"); + exit(1); +} + +// Collection + request body round-trips. +$client->listPets(); +$client->createPet(new Pet(name: 'Smokey')); + +// A non-2xx throws the structured ApiError (a wrong base path 404s every route). +$broken = new Client(new Config(serverUrl: $base . '/nowhere')); +try { + $broken->getPetById(1); + fwrite(STDERR, "expected an ApiError\n"); + exit(1); +} catch (ApiError $error) { + if ($error->status !== 404) { + fwrite(STDERR, "expected 404, got {$error->status}\n"); + exit(1); + } +} + +echo "PHP_SMOKE_OK\n"; diff --git a/tests/e2e/generate-client/php.test.ts b/tests/e2e/generate-client/php.test.ts new file mode 100644 index 0000000000..44ab9741c5 --- /dev/null +++ b/tests/e2e/generate-client/php.test.ts @@ -0,0 +1,92 @@ +import { spawnSync, type ChildProcess } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { generate, killServer, startServer } from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixture = join(__dirname, 'fixtures/base.yaml'); +const consumerDir = join(__dirname, 'php-consumer'); +const generatedFile = join(consumerDir, 'client/client.php'); + +const SERVER_PORT = 3109; +const SERVER_BASE = `http://127.0.0.1:${SERVER_PORT}`; + +const hasPhp = spawnSync('php', ['--version']).status === 0; + +describe('generate-client php generator (end-to-end)', () => { + afterAll(() => { + rmSync(join(consumerDir, 'client'), { recursive: true, force: true }); + }); + + it('generates a self-contained client.php from the CLI', () => { + generate(fixture, join(consumerDir, 'client/client.ts'), ['--generator', 'php']); + expect(existsSync(generatedFile)).toBe(true); + }); + + it.skipIf(!hasPhp)('the generated client parses and declares (php -l + require)', () => { + const lint = spawnSync('php', ['-l', generatedFile], { encoding: 'utf-8' }); + expect(lint.status, `${lint.stdout}\n${lint.stderr}`).toBe(0); + const declare = spawnSync('php', ['-r', `require '${generatedFile}'; echo 'DECLARED';`], { + encoding: 'utf-8', + }); + expect(declare.status, `${declare.stdout}\n${declare.stderr}`).toBe(0); + }); + + it.skipIf(!hasPhp)( + 'the smoke runs real HTTP: hydration, bodies, ApiError', + async () => { + let serverProcess: ChildProcess | undefined; + try { + serverProcess = await startServer( + join(__dirname, 'base-consumer/server.ts'), + join(__dirname, 'base-consumer'), + { BASE_SERVER_PORT: String(SERVER_PORT) }, + SERVER_BASE, + 'php-smoke-server' + ); + const result = spawnSync('php', [join(consumerDir, 'smoke.php'), SERVER_BASE], { + encoding: 'utf-8', + }); + expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain('PHP_SMOKE_OK'); + } finally { + if (serverProcess) await killServer(serverProcess); + } + }, + 60_000 + ); +}); + +describe('generate-client php generator, parameter names an SDK cannot take literally', () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'php-repeated-')); + generate(join(__dirname, 'fixtures/repeated-params.yaml'), join(dir, 'client.ts'), [ + '--generator', + 'php', + ]); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('renames the repeat, keeps the wire name, and still parses', () => { + const source = readFileSync(join(dir, 'client.php'), 'utf-8'); + // `id` in the path and in the query: PHP rejects a redefined parameter outright. + expect(source).toContain('public function getThing(string $id, ?int $id2 = null'); + // A parameter named after one of the signature's own arguments moves aside too. + expect(source).toContain('public function makeThing(string $body2, string $ctx, Thing $body'); + // The request is unchanged: the query keys keep the wire names. + expect(source).toContain("$query['id'] = $id2;"); + }); + + it.skipIf(!hasPhp)('the generated client parses (php -l)', () => { + const lint = spawnSync('php', ['-l', join(dir, 'client.php')], { encoding: 'utf-8' }); + expect(lint.status, `${lint.stdout}\n${lint.stderr}`).toBe(0); + }); +}); diff --git a/tests/e2e/generate-client/plugin.test.ts b/tests/e2e/generate-client/plugin.test.ts index 4ff6a708ea..8ec0b46f97 100644 --- a/tests/e2e/generate-client/plugin.test.ts +++ b/tests/e2e/generate-client/plugin.test.ts @@ -29,7 +29,7 @@ describe('generate-client custom generator (plugin) API', () => { '--output', output, '--generator', - 'sdk', + 'typescript', '--generator', plugin, ]); @@ -59,7 +59,7 @@ describe('generate-client custom generator (plugin) API', () => { '--output', join(dir, 'client.ts'), '--generator', - 'sdk', + 'typescript', '--generator', './route-map-plugin.mjs', '--config', @@ -73,6 +73,38 @@ describe('generate-client custom generator (plugin) API', () => { rmSync(configDir, { recursive: true, force: true }); }, 60_000); + it("validates the generator's declared options from the config block and passes them to run", () => { + const dir = mkdtempSync(join(tmpdir(), 'ots-plugin-options-')); + cpSync(plugin, join(dir, 'route-map-plugin.mjs')); + const config = join(dir, 'redocly.yaml'); + const writeConfig = (options: string) => + writeFileSync( + config, + `extends: []\nclient:\n generators: [typescript, ./route-map-plugin.mjs]\n options:\n route-map:\n${options}` + ); + + writeConfig(' exportName: paths\n'); + const ok = spawnSync( + 'node', + [cliEntry, 'generate-client', cafe, '--output', join(dir, 'client.ts'), '--config', config], + { encoding: 'utf-8', cwd: dir } + ); + expect(ok.status, `${ok.stdout}\n${ok.stderr}`).toBe(0); + expect(readFileSync(join(dir, 'client.routes.ts'), 'utf-8')).toContain( + 'export const paths = {' + ); + + writeConfig(' exportname: paths\n'); + const typo = spawnSync( + 'node', + [cliEntry, 'generate-client', cafe, '--output', join(dir, 'client.ts'), '--config', config], + { encoding: 'utf-8', cwd: dir } + ); + expect(typo.status).not.toBe(0); + expect(`${typo.stdout}\n${typo.stderr}`).toMatch(/unknown option "exportname".*exportName/s); + rmSync(dir, { recursive: true, force: true }); + }, 60_000); + it('fails fast with an actionable message when a specifier cannot be loaded', () => { const dir = mkdtempSync(join(tmpdir(), 'ots-plugin-')); const { status, out } = run([ @@ -80,7 +112,7 @@ describe('generate-client custom generator (plugin) API', () => { '--output', join(dir, 'client.ts'), '--generator', - 'sdk', + 'typescript', '--generator', join(dir, 'missing-plugin.mjs'), ]); diff --git a/tests/e2e/generate-client/python-consumer/smoke.py b/tests/e2e/generate-client/python-consumer/smoke.py new file mode 100644 index 0000000000..5719b78586 --- /dev/null +++ b/tests/e2e/generate-client/python-consumer/smoke.py @@ -0,0 +1,39 @@ +# Runtime smoke for the generated Python SDK, exercised against the same Node +# mock server the TypeScript base consumer uses. Run by python.test.ts with: +# python3 smoke.py +import importlib.util +import sys + +client_path, server_url = sys.argv[1], sys.argv[2] +spec = importlib.util.spec_from_file_location("generated_client", client_path) +module = importlib.util.module_from_spec(spec) +# Register BEFORE exec: dataclass ClassVar annotations resolve through +# sys.modules[cls.__module__] at class-creation time. +sys.modules["generated_client"] = module +spec.loader.exec_module(module) + +client = module.Client(server_url=server_url) + +# Typed call with hydration: the response decodes into the generated dataclasses. +pet = client.get_pet_by_id(1) +assert isinstance(pet, module.Pet), f"expected a Pet dataclass, got {type(pet)!r}" +assert isinstance(pet.name, str) and pet.name, "pet.name should hydrate" + +# A collection response hydrates its element type. +pets = client.list_pets() +assert isinstance(pets, list) and all(isinstance(p, module.Pet) for p in pets) + +# A request body encodes through the dataclass (None fields, like the readOnly +# server-managed id, are omitted from the wire payload by encode()). +created = client.create_pet(body=module.Pet(name="Smokey", status="available")) +assert isinstance(created, module.Pet) + +# A non-2xx raises the structured ApiError (a wrong base path 404s every route). +broken = module.Client(server_url=server_url + "/nowhere") +try: + broken.get_pet_by_id(1) + raise AssertionError("expected ApiError for a 404") +except module.ApiError as error: + assert error.status == 404, f"expected 404, got {error.status}" + +print("PYTHON_SMOKE_OK") diff --git a/tests/e2e/generate-client/python.test.ts b/tests/e2e/generate-client/python.test.ts new file mode 100644 index 0000000000..1005e598a8 --- /dev/null +++ b/tests/e2e/generate-client/python.test.ts @@ -0,0 +1,280 @@ +import { spawnSync, type ChildProcess } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { cliEntry, generate, killServer, startServer } from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixture = join(__dirname, 'fixtures/base.yaml'); +const consumerDir = join(__dirname, 'python-consumer'); +const generatedFile = join(consumerDir, 'client.py'); + +const SERVER_PORT = 3106; +const SERVER_BASE = `http://127.0.0.1:${SERVER_PORT}`; + +const hasPython = spawnSync('python3', ['--version']).status === 0; +const hasHttpx = hasPython && spawnSync('python3', ['-c', 'import httpx']).status === 0; +const hasPydantic = hasPython && spawnSync('python3', ['-c', 'import pydantic']).status === 0; + +describe('generate-client python generator (end-to-end)', () => { + afterAll(() => { + rmSync(generatedFile, { force: true }); + rmSync(join(consumerDir, '__pycache__'), { recursive: true, force: true }); + }); + + it('generates a self-contained client.py from the CLI', () => { + generate(fixture, join(consumerDir, 'client.ts'), ['--generator', 'python']); + expect(existsSync(generatedFile)).toBe(true); + }); + + it.skipIf(!hasPython)('the generated client is valid Python', () => { + const result = spawnSync('python3', ['-m', 'py_compile', generatedFile], { + encoding: 'utf-8', + }); + expect(result.status, result.stderr).toBe(0); + }); + + it.skipIf(!hasHttpx)( + 'runs real HTTP against the mock server: hydration, bodies, ApiError', + async () => { + let serverProcess: ChildProcess | undefined; + try { + serverProcess = await startServer( + join(__dirname, 'base-consumer/server.ts'), + join(__dirname, 'base-consumer'), + { BASE_SERVER_PORT: String(SERVER_PORT) }, + SERVER_BASE, + 'python-smoke-server' + ); + const result = spawnSync( + 'python3', + [join(consumerDir, 'smoke.py'), generatedFile, SERVER_BASE], + { encoding: 'utf-8' } + ); + expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain('PYTHON_SMOKE_OK'); + } finally { + if (serverProcess) await killServer(serverProcess); + } + }, + 60_000 + ); +}); + +describe('generate-client python generator, models: pydantic (end-to-end)', () => { + // `models` is config-only, like every per-generator option, so this drives a config file. + let dir: string; + let generated: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'python-pydantic-')); + writeFileSync( + join(dir, 'redocly.yaml'), + [ + 'apis:', + ' cafe:', + ` root: ${join(__dirname, 'fixtures/cafe.yaml')}`, + ' clientOutput: ./client.ts', + ' client:', + ' generators: [python]', + ' options:', + ' python:', + ' models: pydantic', + ].join('\n'), + 'utf-8' + ); + const result = spawnSync('node', [cliEntry, 'generate-client'], { + cwd: dir, + encoding: 'utf-8', + }); + expect(result.status, result.stderr).toBe(0); + generated = join(dir, 'client.py'); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('emits BaseModel classes and names pydantic in the header', () => { + const source = readFileSync(generated, 'utf-8'); + expect(source).toContain('pip install httpx pydantic'); + expect(source).toContain('from pydantic import BaseModel, ConfigDict, Field'); + expect(source).toContain('(BaseModel):'); + // The client and the runtime are the same in both model modes. + expect(source).toContain('class Client:'); + expect(source).toContain('def decode('); + }); + + it.skipIf(!hasPython)('the generated client is valid Python', () => { + const result = spawnSync('python3', ['-m', 'py_compile', generated], { encoding: 'utf-8' }); + expect(result.status, result.stderr).toBe(0); + }); + + it.skipIf(!hasPydantic)('decodes wire names through aliases and encodes them back', () => { + // One round trip proves the three pieces of this mode: the alias, the runtime + // dispatch to pydantic, and `by_alias` on the way out. + const script = [ + 'import json, sys', + `sys.path.insert(0, ${JSON.stringify(dir)})`, + 'import client', + 'wire = {"customerName": "Sam", "orderItems": [], "id": "ord_1", "totalPrice": 900}', + 'order = client.decode(client.Order, wire)', + 'assert type(order).__name__ == "Order", type(order)', + // The wire name arrives on the aliased field, and leaves on the alias again. + 'assert order.customer_name == "Sam", order', + 'assert order.total_price == 900, order', + 'assert client.encode(order) == wire, client.encode(order)', + // A required field missing must fail loudly: that is what this mode buys. + 'import pydantic', + 'try:', + ' client.decode(client.Order, {"id": "ord_1"})', + ' raise AssertionError("expected a validation error")', + 'except pydantic.ValidationError:', + ' pass', + 'print("PYDANTIC_ROUND_TRIP_OK")', + ].join('\n'); + const result = spawnSync('python3', ['-c', script], { encoding: 'utf-8' }); + expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain('PYDANTIC_ROUND_TRIP_OK'); + }); + + it.skipIf(!hasPydantic)('resolves a discriminated union nested in a model, not by shape', () => { + // Pydantic resolves a nested union itself, so the discriminator has to reach the + // annotation: `MenuItem` lives inside `MenuItemList.items`, never at the top level. + const item = [ + '{"category": "dessert", "calories": 400, "id": "mi_1", "name": "Cake",', + '"price": 500, "createdAt": "2026-01-01T00:00:00Z",', + '"updatedAt": "2026-01-01T00:00:00Z", "object": "menuItem"}', + ].join(' '); + const script = [ + 'import sys', + `sys.path.insert(0, ${JSON.stringify(dir)})`, + 'import client', + `item = ${item}`, + 'page = {"limit": 1, "endCursor": "c", "startCursor": "c",', + ' "hasNextPage": False, "hasPrevPage": False, "total": 1}', + 'listed = client.decode(client.MenuItemList, {"object": "list", "page": page, "items": [item]})', + 'assert type(listed.items[0]).__name__ == "Dessert", type(listed.items[0])', + // The top level goes through the same annotation. + 'assert type(client.decode(client.MenuItem, item)).__name__ == "Dessert"', + 'assert client.encode(listed)["items"][0]["category"] == "dessert"', + 'print("PYDANTIC_DISCRIMINATOR_OK")', + ].join('\n'); + const result = spawnSync('python3', ['-c', script], { encoding: 'utf-8' }); + expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain('PYDANTIC_DISCRIMINATOR_OK'); + }); +}); + +describe('generate-client python generator, parameter names an SDK cannot take literally', () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'python-repeated-')); + generate(join(__dirname, 'fixtures/repeated-params.yaml'), join(dir, 'client.ts'), [ + '--generator', + 'python', + ]); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('renames the repeat, keeps the wire name, and still parses', () => { + const source = readFileSync(join(dir, 'client.py'), 'utf-8'); + // `id` in the path and in the query: the later one moves aside… + expect(source).toContain('def get_thing(self, id: str, *, id_2: Optional[int] = None'); + // …and a parameter named after one of the method's own arguments does too. + expect(source).toContain('def make_thing(self, body_2: str, ctx: str, body: Thing, *,'); + expect(source).toContain('timeout_2: Optional[str] = None'); + // The request is unchanged: the descriptor and the query keys keep the wire names. + expect(source).toContain('params["id"] = encode(id_2)'); + expect(source).toContain('params["timeout"] = encode(timeout_2)'); + }); + + it.skipIf(!hasPython)('the generated client is valid Python', () => { + const result = spawnSync('python3', ['-m', 'py_compile', join(dir, 'client.py')], { + encoding: 'utf-8', + }); + expect(result.status, result.stderr).toBe(0); + }); +}); + +describe('generate-client python generator, a paginated operation under a path parameter', () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'python-page-path-')); + writeFileSync( + join(dir, 'openapi.yaml'), + [ + 'openapi: 3.1.0', + 'info: { title: Nested, version: 1.0.0 }', + 'servers: [{ url: http://127.0.0.1:3141 }]', + 'paths:', + ' /orders/{orderId}/items:', + ' get:', + ' operationId: listOrderItems', + ' x-redoclyPagination:', + ' { style: cursor, cursorParam: cursor, nextCursor: /next, items: /items }', + ' parameters:', + ' - { name: orderId, in: path, required: true, schema: { type: string } }', + ' - { name: cursor, in: query, required: false, schema: { type: string } }', + ' responses:', + " '200':", + ' description: ok', + ' content:', + ' application/json:', + ' schema:', + ' type: object', + ' properties:', + ' items: { type: array, items: { type: object } }', + ' next: { type: string }', + '', + ].join('\n'), + 'utf-8' + ); + generate(join(dir, 'openapi.yaml'), join(dir, 'client.ts'), ['--generator', 'python']); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it.skipIf(!hasHttpx)('substitutes the path value into every page request', () => { + // The iterator serves its own pages here: what matters is the URL it asks for, which + // used to be the template itself (`/orders/{orderId}/items`) with the value dropped. + const script = [ + 'import json, sys, threading', + 'from http.server import BaseHTTPRequestHandler, HTTPServer', + 'seen = []', + 'class Handler(BaseHTTPRequestHandler):', + ' def do_GET(self):', + ' seen.append(self.path)', + ' first = "cursor=" not in self.path', + ' body = {"items": [{"id": "i1"}], "next": "c2" if first else None}', + ' payload = json.dumps(body).encode()', + ' self.send_response(200)', + ' self.send_header("content-type", "application/json")', + ' self.send_header("content-length", str(len(payload)))', + ' self.end_headers()', + ' self.wfile.write(payload)', + ' def log_message(self, *args):', + ' pass', + 'server = HTTPServer(("127.0.0.1", 3141), Handler)', + 'threading.Thread(target=server.serve_forever, daemon=True).start()', + `sys.path.insert(0, ${JSON.stringify(dir)})`, + 'import client', + 'pages = list(client.Client().list_order_items_pages("ord_7"))', + 'assert len(pages) == 2, pages', + 'assert seen == ["/orders/ord_7/items", "/orders/ord_7/items?cursor=c2"], seen', + 'print("PYTHON_PAGE_PATH_OK")', + ].join('\n'); + const result = spawnSync('python3', ['-c', script], { encoding: 'utf-8' }); + expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain('PYTHON_PAGE_PATH_OK'); + }); +}); diff --git a/tests/e2e/generate-client/query-styles.test.ts b/tests/e2e/generate-client/query-styles.test.ts index 054d053d47..661a62e0a9 100644 --- a/tests/e2e/generate-client/query-styles.test.ts +++ b/tests/e2e/generate-client/query-styles.test.ts @@ -69,7 +69,7 @@ describe('generate-client query serialization styles', () => { ` return new Response('{"results":[]}', { status: 200, headers: { 'content-type': 'application/json' } });`, ` },`, `});`, - `await search({ tags: ['a', 'b'], q: ['x', 'y'], ids: ['1', '2'], filter: 'a/b', limit: 5 });`, + `await search({ query: { tags: ['a', 'b'], q: ['x', 'y'], ids: ['1', '2'], filter: 'a/b', limit: 5 } });`, `process.stdout.write(captured);`, ``, ].join('\n'), diff --git a/tests/e2e/generate-client/redocly-config.test.ts b/tests/e2e/generate-client/redocly-config.test.ts index da7662be32..ec62077ed1 100644 --- a/tests/e2e/generate-client/redocly-config.test.ts +++ b/tests/e2e/generate-client/redocly-config.test.ts @@ -75,14 +75,14 @@ describe('generate-client redocly.yaml config', () => { const dir = project( [ 'client:', // shared defaults, inherited by apis without their own block - ' generators: [sdk]', + ' generators: [typescript]', ' serverUrl: https://shared.example.com', 'apis:', ' cafe:', ' root: ./openapi.yaml', ' clientOutput: ./src/cafe.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' outputOnly:', // `clientOutput` alone also opts in ' root: ./openapi.yaml', ' clientOutput: ./src/output-only.ts', @@ -111,7 +111,7 @@ describe('generate-client redocly.yaml config', () => { ' cafe:', ' root: ./openapi.yaml', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ].join('\n') + '\n' ); const res = run(dir); @@ -128,7 +128,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' serverUrl: https://per-api.example.com', ].join('\n') + '\n' ); @@ -144,7 +144,7 @@ describe('generate-client redocly.yaml config', () => { const dir = project( [ 'client:', - ' generators: [sdk]', + ' generators: [typescript]', ' serverUrl: https://top-level.example.com', 'apis:', ' cafe:', @@ -161,13 +161,33 @@ describe('generate-client redocly.yaml config', () => { rmSync(dir, { recursive: true, force: true }); }, 60_000); + it('client.codeSamples emits an x-codeSamples overlay next to the client', () => { + const dir = project( + [ + 'apis:', + ' cafe:', + ' root: ./openapi.yaml', + ' clientOutput: ./out.ts', + ' client:', + ' generators: [typescript]', + ' codeSamples: true', + ].join('\n') + '\n' + ); + const res = run(dir, ['cafe']); + expect(res.status, res.stderr).toBe(0); + const overlay = readFileSync(join(dir, 'out.code-samples.yaml'), 'utf-8'); + expect(overlay).toContain('x-codeSamples'); + expect(overlay).toContain('lang: typescript'); + rmSync(dir, { recursive: true, force: true }); + }, 60_000); + it('a per-api client block REPLACES the top-level one (no field-by-field merging)', () => { // One resolution path, obvious to reason about: an api with its own `client` // uses that block wholesale; the top-level block only serves apis without one. const dir = project( [ 'client:', - ' generators: [sdk, zod]', + ' generators: [typescript, zod]', ' errorMode: result', 'apis:', ' cafe:', @@ -193,13 +213,13 @@ describe('generate-client redocly.yaml config', () => { const dir = project( [ 'client:', - ' generators: [sdk]', + ' generators: [typescript]', ' serverUrl: https://top-level.example.com', 'apis:', ' cafe:', ' root: ./openapi.yaml', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' serverUrl: https://per-api.example.com', ].join('\n') + '\n' ); @@ -226,7 +246,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' serverUrl: https://per-api.example.com', ].join('\n') + '\n' ); @@ -246,7 +266,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' facade: service-class', // removed option -> property-not-expected warning ].join('\n') + '\n' ); @@ -266,7 +286,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' pagination:', ' style: cursor', ' cursorParam: after', @@ -282,8 +302,8 @@ describe('generate-client redocly.yaml config', () => { const out = readFileSync(join(dir, 'out.ts'), 'utf-8'); // The convention fits the cursor-style list operations -> descriptor pagination… expect(out).toContain('pagination: {'); - // …and the flat sugar preserves the method-attached iterators. - expect(out).toContain('items: client.listOrders.items'); + // …and the exported binding IS the method, so `.items()` rides along with it. + expect(out).toContain('listOrders, '); rmSync(dir, { recursive: true, force: true }); }, 60_000); @@ -295,7 +315,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' pagination:', ' operations:', ' getRevenue:', // has no `after` query param -> explicit misfit = error @@ -322,7 +342,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' pagination:', ' style: cursor', ' cursor_param: after', // unknown key -> property-not-expected warning @@ -346,7 +366,7 @@ describe('generate-client redocly.yaml config', () => { ' cafe:', ' root: ./openapi.yaml', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ].join('\n') + '\n' ); const res = run(dir, ['--output', './out.ts']); @@ -370,16 +390,16 @@ describe('generate-client redocly.yaml config', () => { ' a:', ' root: ./openapi.yaml', ' clientOutput: ./dupe.ts', - ' client: { generators: [sdk] }', + ' client: { generators: [typescript] }', ' b:', ' root: ./openapi.yaml', ' clientOutput: ./dupe.ts', - ' client: { generators: [sdk] }', + ' client: { generators: [typescript] }', ].join('\n') + '\n' ); const res = run(dir); expect(res.status).not.toBe(0); - expect(res.stderr).toContain('resolve to the same output path'); + expect(res.stderr).toContain('Two APIs write to the same path'); rmSync(dir, { recursive: true, force: true }); }, 60_000); @@ -392,7 +412,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ` serverUrl: ${serverUrl}`, ].join('\n') + '\n' ); @@ -428,7 +448,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' setup: https://cdn.example.com/setup.ts', ].join('\n') + '\n' ); @@ -446,7 +466,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' runtime: package', ].join('\n') + '\n' ); @@ -466,7 +486,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out/client.ts', ' client:', - ' generators: [sdk, zod]', + ' generators: [typescript, zod]', ' outputMode: split', ' runtime: package', ].join('\n') + '\n' @@ -490,7 +510,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out/client.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ].join('\n') + '\n' ); // Run from the repo root, pointing at the config elsewhere via --config. @@ -532,7 +552,7 @@ describe('generate-client redocly.yaml config', () => { ' decorators:', ' remove-x-internal: on', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ].join('\n') + '\n', 'utf-8' ); diff --git a/tests/e2e/generate-client/retry.test.ts b/tests/e2e/generate-client/retry.test.ts index b4c9eb87e0..34e4137165 100644 --- a/tests/e2e/generate-client/retry.test.ts +++ b/tests/e2e/generate-client/retry.test.ts @@ -109,13 +109,13 @@ describe('retry behavior', () => { // default predicate: POST is not idempotent → no retry. calls = 0; configure({ fetch: failing, retry: { retries: 3, retryDelay: 1 } }); - try { await createPet({ name: 'x' } as any); } catch {} + try { await createPet({ body: { name: 'x' } } as any); } catch {} const defaultCalls = calls; // retryOn: () => true → POST retried. calls = 0; configure({ fetch: failing, retry: { retries: 3, retryDelay: 1, retryOn: () => true } }); - try { await createPet({ name: 'x' } as any); } catch {} + try { await createPet({ body: { name: 'x' } } as any); } catch {} const optInCalls = calls; console.log(JSON.stringify({ defaultCalls, optInCalls })); diff --git a/tests/e2e/generate-client/spec-versions.test.ts b/tests/e2e/generate-client/spec-versions.test.ts index e796427e48..bd254ddaa2 100644 --- a/tests/e2e/generate-client/spec-versions.test.ts +++ b/tests/e2e/generate-client/spec-versions.test.ts @@ -21,15 +21,14 @@ function generateAndTypecheck(fixture: string): { generated: string } { describe('generate-client spec versions', () => { it('generates a type-checking client from a Swagger 2.0 document', () => { const { generated } = generateAndTypecheck('swagger2.yaml'); - expect(generated).toContain('export const getPet = { const { generated } = generateAndTypecheck('oas3.2.yaml'); - expect(generated).toContain('export const getThing = { it('synthesizes operation names from method+path when operationId is omitted', () => { const { generated } = generateAndTypecheck('no-operationid.yaml'); - expect(generated).toContain('export const getGiftcardsCardId = { 'export const client = createClient(OPERATIONS,' ); expect(entrySrc).toContain('export const { configure, use } = client;'); - expect(entrySrc).toContain('export const setBearer = client.auth.bearer;'); + expect(entrySrc).toContain('export const { configure, use } = client;'); // Schemas holds the model types and the discriminated-union guards. const schemasSrc = readFileSync(schemasFile, 'utf-8'); diff --git a/tests/e2e/generate-client/sse-consumer/index-abort.ts b/tests/e2e/generate-client/sse-consumer/index-abort.ts index f3f69e20f1..8da9d6c9e1 100644 --- a/tests/e2e/generate-client/sse-consumer/index-abort.ts +++ b/tests/e2e/generate-client/sse-consumer/index-abort.ts @@ -12,7 +12,7 @@ async function main(): Promise { let error: string | null = null; try { - for await (const ev of streamAbort({ signal: controller.signal })) { + for await (const ev of streamAbort({}, { signal: controller.signal })) { void ev; received++; // Abort mid-stream, after the first event, while the server holds open. diff --git a/tests/e2e/generate-client/sse-consumer/index-connect-retry.ts b/tests/e2e/generate-client/sse-consumer/index-connect-retry.ts index 9e4542f47b..61bd40f584 100644 --- a/tests/e2e/generate-client/sse-consumer/index-connect-retry.ts +++ b/tests/e2e/generate-client/sse-consumer/index-connect-retry.ts @@ -22,7 +22,7 @@ configure({ async function main(): Promise { const events: string[] = []; // Tiny reconnect backoff so the test doesn't wait on the 1s default. - for await (const ev of streamMessages({ reconnectDelay: 1 })) { + for await (const ev of streamMessages({}, { reconnectDelay: 1 })) { events.push(ev.data.text); } process.stdout.write(JSON.stringify({ calls, events, finished: true }) + '\n'); diff --git a/tests/e2e/generate-client/sse.test.ts b/tests/e2e/generate-client/sse.test.ts index 367dfa2a2d..03e4d5144d 100644 --- a/tests/e2e/generate-client/sse.test.ts +++ b/tests/e2e/generate-client/sse.test.ts @@ -56,9 +56,9 @@ describe('generate-client SSE', () => { 'streamTicks: { id: "streamTicks", method: "GET", path: "/ticks", tags: ["Ticks"], responseKind: "sse", sseDataKind: "text" }' ); expect(generated).toMatch(/streamTicks: \{\s*args: \{\};\s*result: string;\s*kind: "sse";/); - // Flat call sugar: an SSE op is a top-level export returning the async generator. + // The binding is the client's own method, which returns the async generator. expect(generated).toContain( - 'export const streamMessages = (init: SseOptions = {}) => client.streamMessages({}, init);' + 'export const { getHealth, streamMessages, streamAbort, streamTicks } = client;' ); // A type-usage snippet proving `ServerSentEvent.data.text` is typed @@ -69,7 +69,7 @@ describe('generate-client SSE', () => { `import { streamMessages, configure } from './client.js';`, `async function check() {`, ` for await (const ev of streamMessages()) { const t: string = ev.data.text; void t; const id: string | undefined = ev.id; void id; }`, - ` const it = streamMessages({ reconnect: false, reconnectDelay: 500 });`, + ` const it = streamMessages({}, { reconnect: false, reconnectDelay: 500 });`, ` void it;`, `}`, `void check; void configure;`, @@ -90,7 +90,9 @@ describe('generate-client SSE', () => { const entrySrc = readFileSync(entry, 'utf-8'); expect(entrySrc).toContain('async function* sse('); - expect(entrySrc).toContain('export const streamMessages = (init: SseOptions = {})'); + expect(entrySrc).toContain( + 'export const { getHealth, streamMessages, streamAbort, streamTicks } = client;' + ); const files = collectTsFiles(dir); expect(files.map((f) => f.split('/').pop()).sort()).toEqual(['client.schemas.ts', 'client.ts']); diff --git a/tests/e2e/generate-client/swr.test.ts b/tests/e2e/generate-client/swr.test.ts index 3790d53a3a..b9af13b646 100644 --- a/tests/e2e/generate-client/swr.test.ts +++ b/tests/e2e/generate-client/swr.test.ts @@ -20,7 +20,7 @@ describe('generate-client swr generator', () => { generate(join(__dirname, 'fixtures', 'base.yaml'), out, [ '--generator', - 'sdk', + 'typescript', '--generator', 'swr', ]); diff --git a/tests/e2e/generate-client/tanstack-query.runtime.test.ts b/tests/e2e/generate-client/tanstack-query.runtime.test.ts index befc7b3d43..7d19eaec0a 100644 --- a/tests/e2e/generate-client/tanstack-query.runtime.test.ts +++ b/tests/e2e/generate-client/tanstack-query.runtime.test.ts @@ -2,7 +2,7 @@ // // Tier-3 runtime React-hook integration for the tanstack-query generator. // -// MECHANISM (documented choice): we generate `sdk,tanstack-query` into a fixed, +// MECHANISM (documented choice): we generate `typescript,tanstack-query` into a fixed, // checked-in consumer dir (`tanstack-consumer/`) and dynamic-`import()` the // generated `client.tanstack.ts` directly — vite transforms it and resolves its // `./client.js` import to the sibling `.ts` reliably (verified). The data is @@ -48,7 +48,7 @@ describe('generate-client tanstack-query runtime (React hooks, jsdom)', () => { } generate(join(__dirname, 'fixtures', 'base.yaml'), sdkFile, [ '--generator', - 'sdk', + 'typescript', '--generator', 'tanstack-query', ]); @@ -78,7 +78,7 @@ describe('generate-client tanstack-query runtime (React hooks, jsdom)', () => { }, }); - const { result } = renderHook(() => useQuery(mod.getPetByIdOptions({ id: 1 })), { + const { result } = renderHook(() => useQuery(mod.getPetByIdOptions({ path: { id: 1 } })), { wrapper: wrapper(newClient()), }); diff --git a/tests/e2e/generate-client/tanstack-query.test.ts b/tests/e2e/generate-client/tanstack-query.test.ts index ea8dfbcbe0..e489861087 100644 --- a/tests/e2e/generate-client/tanstack-query.test.ts +++ b/tests/e2e/generate-client/tanstack-query.test.ts @@ -23,7 +23,7 @@ describe('generate-client tanstack-query generator', () => { generate(join(__dirname, 'fixtures', 'base.yaml'), out, [ '--generator', - 'sdk', + 'typescript', '--generator', 'tanstack-query', ]); @@ -52,13 +52,13 @@ describe('generate-client tanstack-query generator', () => { "import { createPetMutation, getPetByIdOptions, listPetsOptions } from './client.tanstack.js';", "import type { Pet } from './client.js';", 'export function useGetPet(id: number) {', - ' const query = useQuery(getPetByIdOptions({ id }));', + ' const query = useQuery(getPetByIdOptions({ path: { id } }));', ' // Wrapper inits exclude `envelope`: cached data is the plain body, never an envelope.', ' const pet: Pet | undefined = query.data;', ' return pet;', '}', 'export function useListPets() {', - " return useQuery(listPetsOptions({ params: { filter: { name: 'rex' } } }));", + " return useQuery(listPetsOptions({ query: { filter: { name: 'rex' } } }));", '}', 'export function useCreatePet() {', ' return useMutation(createPetMutation());', @@ -104,7 +104,7 @@ describe('generate-client tanstack-query generator', () => { '--runtime', 'package', '--generator', - 'sdk', + 'typescript', '--generator', 'tanstack-query', ]); @@ -127,10 +127,10 @@ describe('generate-client tanstack-query generator', () => { "import { useMutation, useQuery } from '@tanstack/react-query';", "import { createPetMutation, getPetByIdOptions, listPetsOptions } from './client.tanstack.js';", 'export function useGetPet(id: number) {', - ' return useQuery(getPetByIdOptions({ id }));', + ' return useQuery(getPetByIdOptions({ path: { id } }));', '}', 'export function useListPets() {', - " return useQuery(listPetsOptions({ params: { filter: { name: 'rex' } } }));", + " return useQuery(listPetsOptions({ query: { filter: { name: 'rex' } } }));", '}', 'export function useCreatePet() {', ' return useMutation(createPetMutation());', @@ -177,7 +177,7 @@ describe('generate-client tanstack-query generator', () => { generate(join(__dirname, 'fixtures', 'base.yaml'), out, [ '--generator', - 'sdk', + 'typescript', '--generator', 'tanstack-query-vue', ]); diff --git a/tests/e2e/generate-client/transformers.test.ts b/tests/e2e/generate-client/transformers.test.ts index 5f9b34793c..54495ea9b5 100644 --- a/tests/e2e/generate-client/transformers.test.ts +++ b/tests/e2e/generate-client/transformers.test.ts @@ -2,7 +2,7 @@ // e2e for the `transformers` generator paired with the sdk `--date-type Date` // knob. Two tiers: // -// - TYPE-CHECK: generate `sdk,transformers --date-type Date` into a temp dir, +// - TYPE-CHECK: generate `typescript,transformers --date-type Date` into a temp dir, // assert the sdk types `Date` for date fields and the transformers module has // `transform` with `new Date(`, then strict-`tsc` `client.ts` + // `client.transformers.ts` TOGETHER. tsc exit 0 proves each generated @@ -43,7 +43,7 @@ describe('generate-client transformers generator', () => { const out = join(dir, 'client.ts'); const transformersOut = join(dir, 'client.transformers.ts'); - generate(out, ['sdk,transformers', '--date-type', 'Date']); + generate(out, ['typescript,transformers', '--date-type', 'Date']); expect(existsSync(out)).toBe(true); expect(existsSync(transformersOut)).toBe(true); @@ -70,7 +70,7 @@ describe('generate-client transformers generator', () => { const transformersFile = join(consumerDir, 'client.transformers.ts'); for (const f of [sdkFile, transformersFile]) if (existsSync(f)) rmSync(f, { force: true }); - generate(sdkFile, ['sdk,transformers', '--date-type', 'Date']); + generate(sdkFile, ['typescript,transformers', '--date-type', 'Date']); expect(existsSync(transformersFile)).toBe(true); const mod = await import(transformersFile); @@ -98,7 +98,7 @@ describe('generate-client transformers generator', () => { it('without --date-type Date the sdk date field stays typed string (default)', () => { const dir = mkdtempSync(join(tmpdir(), 'ots-transformers-default-')); const out = join(dir, 'client.ts'); - generate(out, ['sdk']); + generate(out, ['typescript']); expect(readFileSync(out, 'utf-8')).toContain('createdAt?: string;'); rmSync(dir, { recursive: true, force: true }); }, 60_000); diff --git a/tests/e2e/generate-client/zod.test.ts b/tests/e2e/generate-client/zod.test.ts index eaa28670f1..3b458eeb5a 100644 --- a/tests/e2e/generate-client/zod.test.ts +++ b/tests/e2e/generate-client/zod.test.ts @@ -28,7 +28,7 @@ describe('generate-client zod generator', () => { generate(join(__dirname, 'fixtures', 'cafe.yaml'), out, [ '--generator', - 'sdk', + 'typescript', '--generator', 'zod', ]); @@ -140,7 +140,7 @@ describe('generate-client zod generator', () => { ); generate(join(dir, 'openapi.yaml'), join(dir, 'client.ts'), [ '--generator', - 'sdk', + 'typescript', '--generator', 'zod', ]); diff --git a/vitest.config.ts b/vitest.config.ts index d9d4407c92..efd0491433 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -32,6 +32,22 @@ const configExtension: { [key: string]: ViteUserConfig } = { e2e: defineConfig({ test: { include: ['tests/e2e/**/*.test.ts'], + // Client generation has its own suite and its own CI job (see `generators` below): + // its bars compile real Python/Go/PHP/TypeScript output, so a growing set of them + // must not slow the job everything else shares. + exclude: ['tests/e2e/generate-client/**'], + }, + }), + // Everything about client generation in one command: the package's unit tests plus the + // end-to-end bars. The unit tests also run under `unit`, which keeps the coverage report + // whole — they are seconds, and being able to run the whole generator surface at once is + // worth that. + 'client-generators': defineConfig({ + test: { + include: [ + 'packages/client-generator/src/**/*.test.ts', + 'tests/e2e/generate-client/**/*.test.ts', + ], }, }), 'smoke-rebilly': defineConfig({