From 75f9e0073e9fbf1501e69a57d3d7b7a61332cf7d Mon Sep 17 00:00:00 2001 From: Roman Hotsiy Date: Fri, 21 Aug 2026 14:41:17 +0800 Subject: [PATCH 01/35] docs(client-generator): record the generator-rewrite decisions and analyze the helper surface Three ADRs for the self-contained generator rewrite, plus the measured inventory they rest on. ADR-0020 makes every generator a self-contained folder ejected as source, replacing the esbuild bundle that inlines already-public toolkit code. ADR-0021 records the text-printer architecture the code already uses and supersedes ADR-0001, which still documents the removed ts.factory codegen. ADR-0022 drops runtime: package for a sibling runtime module, and amends ADR-0017 point 3. helper-surface.md catalogues twelve duplications with file:line evidence. Three are defects rather than untidiness: two TypeScript string escapers with different escaping policies, no escaper at all in python and go, and two pagination resolvers that can disagree about whether an operation paginates. --- .../0020-self-contained-generator-folders.md | 57 +++++ .../docs/adr/0021-text-printers.md | 61 +++++ .../docs/adr/0022-runtime-inline-or-module.md | 53 ++++ packages/client-generator/docs/adr/README.md | 7 +- .../client-generator/docs/helper-surface.md | 235 ++++++++++++++++++ 5 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 packages/client-generator/docs/adr/0020-self-contained-generator-folders.md create mode 100644 packages/client-generator/docs/adr/0021-text-printers.md create mode 100644 packages/client-generator/docs/adr/0022-runtime-inline-or-module.md create mode 100644 packages/client-generator/docs/helper-surface.md diff --git a/packages/client-generator/docs/adr/0020-self-contained-generator-folders.md b/packages/client-generator/docs/adr/0020-self-contained-generator-folders.md new file mode 100644 index 0000000000..3446ae2eb5 --- /dev/null +++ b/packages/client-generator/docs/adr/0020-self-contained-generator-folders.md @@ -0,0 +1,57 @@ +# ADR 0020: Self-contained generator folders, ejected as source + +- Status: Accepted +- Date: 2026-08-21 + +## Context + +Built-in generators have two incompatible shapes, and `redocly eject-generator` papers over the difference. + +`python`, `go`, and `php` are each one self-contained file that imports only the neutral toolkit. +Ejecting one type-strips its own source, so the user reads what we wrote. +The other seven — `typescript`, `zod`, `mock`, `swr`, `tanstack-query`, `transformers`, `cli` — are thin entries over shared `emitters/` modules. +Ejecting one **esbuild-bundles about 24 modules**: the result opens with `__defProp`/`__name` shims, ends with a renamed `entry_typescript_default`, and inlines copies of `authoring/printer.ts`, `authoring/schema.ts`, `authoring/pagination.ts`, and `authoring/reference-page.ts` — code that is already public API and should have been imported. +At 178 kB it is compiler output, not a file anyone owns. + +Three further problems follow from the split: + +1. **The import rewrite is a string swap.** The eject build does `.replaceAll("'../../authoring/index.js'", "'@redocly/client-generator'")`. Nothing stops a generator from deep-importing `../../authoring/schema.js` or any private emitter, which would silently ship a broken eject. +2. **The two shapes hide that the generators are the same pipeline.** `pythonGenerator` emits header → models → servers → embedded runtime → descriptor table → client class. `emitClient` emits header → schema statements → servers → embedded runtime → ops wiring → descriptor table → client section. Eleven stages line up 1:1. The difference is organizational drift, not architecture. +3. **`emitters/` mixes three unrelated things** — one generator's body, genuinely shared syntax helpers, and IR analysis that contains no TypeScript at all (see [`../helper-surface.md`](../helper-surface.md)). + +Measured at symbol level, the seven TypeScript generators share **four functions totalling 27 lines** (`safeIdent`, `pascalCase`, `codeLiteral`, `codeString`). +The rest of `emitters/` is single-owner. +The fear that self-contained generators would duplicate a large shared TypeScript layer is not supported by the code. + +## Decision + +**Every generator is a self-contained folder, and ejecting copies that folder as TypeScript source.** + +1. **One skeleton for every language.** A generator folder is `AGENTS.md`, `index.ts` (`run`/`sample`/`docs`/`options`), and one file per pipeline stage: `naming`, `types`, `models`, `descriptor`, `operations`, `pagination`, `client`, plus `runtime/` where the generator embeds one. + The skeleton is **descriptive, not prescriptive** — a language omits a stage it does not have (python has no `split`, zod has no `client`), and there are no empty placeholder files. + An agent that has read `generators/python/` can navigate `generators/typescript/` without re-learning. +2. **`emitters/` is dissolved.** Each module moves to the generator that owns it, to a language printer ([ADR-0021](./0021-text-printers.md)), or to the neutral toolkit. +3. **Three import rules, enforced by a guard test.** A generator folder may import only its own files, `@redocly/client-generator`, `@redocly/client-generator/printers/`, `@redocly/client-generator/runtime-sources`, and the contract of a generator it `requires`. + No relative import may leave the folder. + `language-dogfooding.test.ts` generalizes from three generators to all ten. +4. **Package specifiers in source, resolved by `paths`.** Source imports the same specifier the ejected file does; a tsconfig `paths` entry maps it to `src/` for typechecking. + The `replaceAll` rewrite is deleted, and the source/ejected import lines become byte-identical. +5. **Sharing has four tiers, and only four.** The neutral toolkit (IR analysis, contract types, `Printer`); the language printer (syntax); `runtime-sources`; and a required generator's published **contract**. + `contracts/typescript` exports the generated SDK's ABI — `operationSignature`, `variablesName`, `sdkCallText`, `wrappableOperations`, `flatInputShape` — for the generators that declare `requires: ['typescript']`. + A generator may never import another generator's internals. +6. **Eject copies the folder as `.ts`.** No esbuild, no bundling, no synthesized entry module, no import rewriting. + The descriptor default export is still appended from `BUILTIN_META`, which keeps `meta.ts`'s laziness intact. + `--update` merges per file with the three-way merge already used for skills. +7. **Ejected `.ts` requires a Node floor check at the point of use.** Built-in generators compile to `lib/*.js` and are unaffected; only an ejected folder is TypeScript. + The resolver checks the running Node version when an entry resolves to a `.ts` file and errors with the required version. + +## Consequences + +- A user who ejects `typescript` owns eight readable files averaging about 200 lines instead of one 178 kB bundle. Ejected code is the code we wrote, in every language. +- Ejected generators keep full type checking against the IR's 273 lines of model types. An agent editing an ejected generator gets errors at edit time rather than at generation time — the largest single agent-affordance in this plan. +- A `--update` conflict lands in one stage file instead of anywhere in an 1800-line bundle. +- The four sharing tiers are mechanically checkable, so "accidentally imported something not exposed" stops being possible rather than becoming a review item. +- **Cost: a large mechanical migration.** Thirty-plus modules move, and python, go, and php each split from one ~1000-line file into about eight. The diff is enormous and mostly moves. +- **Cost: ejecting requires a newer Node.** Anyone on the current floor who ejects gets a clear error instead of a working generator until they upgrade. +- **Cost: `contracts/typescript` is a new public surface** to version and document. It is the honest name for a dependency that already exists — `swr` and `tanstack-query` already code against the TypeScript SDK's calling convention — but naming it makes it a compatibility obligation. +- Divergence between an ejected generator and a package-side assumption stays possible. The `requiresGenerator` range already in the ejected descriptor is the place to extend a contract-version check. diff --git a/packages/client-generator/docs/adr/0021-text-printers.md b/packages/client-generator/docs/adr/0021-text-printers.md new file mode 100644 index 0000000000..d1ad3d10b6 --- /dev/null +++ b/packages/client-generator/docs/adr/0021-text-printers.md @@ -0,0 +1,61 @@ +# ADR 0021: Text printers — one common printer plus one per language + +- Status: Accepted +- Date: 2026-08-21 +- Supersedes: [ADR-0001](./0001-ast-codegen.md) + +## Context + +[ADR-0001](./0001-ast-codegen.md) chose `ts.factory` AST codegen and is still marked Accepted, but the code has not worked that way for some time: `emitters/ts.ts` and `emitters/package-client.ts` no longer exist, and every built-in generator emits text. +That migration happened because the generator gained non-TypeScript output languages, and an AST for TypeScript does nothing for Python, Go, or PHP. +This ADR records the shape the code actually has, and settles what belongs in a shared printer. + +The text layer today is inconsistent in ways that are more than cosmetic — the full inventory is in [`../helper-surface.md`](../helper-surface.md), and the load-bearing findings are: + +- **Two identifier systems.** `authoring/naming.ts` states it in its own header: *"TypeScript keeps its specialized sanitizer in emitters/identifier.ts; this is for the other output languages."* The TypeScript reserved-word list exists twice, and the two systems disagree on convention — `sanitizeIdentifier` prefixes (`_class`), `identifierFor` suffixes (`class_`). +- **Two TypeScript string escapers with different security policies.** `codeString` escapes U+2028/U+2029; `sanitizeCodeString` also escapes `<`/`>` to prevent a `` breakout. Which protection applies depends on which one the caller imported. +- **Python and Go have no string escaper at all** — 19 and 28 raw `JSON.stringify` calls respectively, relying on JSON escaping being close enough to each language's literal syntax. +- **Four hand-rolled doc-comment writers**, each re-deriving real per-language rules: Go collapses consecutive blank comment lines because gofmt rewrites `//\n//`; TypeScript must escape `*/` because `info.title` is attacker-controllable; Python has distinct one-line and multi-line docstring forms; PHP needs `@tag` lines because its type syntax erases element types. +- **Indent units are passed at call sites** — `new Printer(' ')`, `new Printer('\t')`. + +Two alternatives were considered and rejected. + +**Prettier's Doc IR** (the Wadler/Oppen algebra behind `group`/`line`/`indent`) would buy automatic line-width breaking, which is a genuine gap — generated output is hand-formatted with no post-pass. +It was rejected because `group([indent([line, …])])` hides the emitted text, and Prettier's own architecture argues against it here: Prettier has no universal syntax model either, only a universal *layout* engine plus a hand-written printer per language. +Its printers run to thousands of lines because they must handle every possible program; ours emit roughly fifteen constructs per language. +We do not have Prettier's problem. + +**Tree-sitter** is a parser with no unparser, and there is no universal AST or codegen spec to adopt (UAST is dead; srcML covers a few C-family languages). + +**Delegating to real formatters** (prettier, black, gofmt, php-cs-fixer) was rejected because those tools are not available in a Node CLI, so formatting would depend on what is on `PATH` — breaking the determinism rule that the same description produces the same bytes. + +## Decision + +**Generated code is text, built by a common structural printer plus one syntax printer per output language.** + +1. **The common `Printer` owns structure only** — `line`, `blank`, `lines`, `indent`, `block`, `toString` — and stays in the neutral toolkit. +2. **A language printer extends it with syntax**, one per output language, with a common core: `typeName`, `memberName`, `identifier`, `identifiers`, `string`, `literal`, `comment`, `doc`, a baked-in `indentUnit`, and a `layout(source)` pass that `toString()` applies. + `identifier` is spelled out rather than abbreviated; `ident` is too easily misread as `indent` at the call sites where both appear. +3. **The boundary is syntax versus shape.** The printer owns identifier safety, string escaping, literal rendering, comment and doc syntax, indentation, and whole-file layout. + The generator owns everything that decides output shape — classes, functions, signatures, field lists — written as template literals. + The test for whether a method belongs on the printer: **is there exactly one right answer?** + `py.string("it's")` has one. `py.dataclass(name, fields)` has a hundred (frozen? slots? kw_only?), which makes it a design decision, and design decisions must stay visible in the generator. +4. **The boundary is enforced, not agreed.** A guard test asserts each generator's source still contains the literal keywords it emits, so an agent asked to make dataclasses frozen finds `@dataclass` on a line and edits it, rather than needing to read a printer that is not in the ejected folder. +5. **Per-language extensions are kept, not flattened.** Only TypeScript has quotable object keys (`key`); only PHP needs doc `tags`; only Go needs `layout` and an exported-ness rule; only Python needs `memberName` to report that it renamed, for `_field_map`. + Forcing a lowest common denominator would lose real language knowledge — notably Go's `_`→`N` rule, where `identifierFor`'s `_` prefix for a digit-leading name means **unexported**, so `encoding/json` would silently skip the field. +6. **The duplicates collapse.** One TypeScript reserved-word list, one TypeScript string escaper (on the stricter policy), one doc-comment path per language. + +`layout()` exists because Go demands byte-exact `gofmt` output: CI commonly runs `gofmt -l` and fails on any file it would reformat, and column alignment cannot be computed line-by-line — the padding for the first field depends on the longest field in a run that has not been emitted yet. +It is the identity function for TypeScript, Python, and PHP. + +## Consequences + +- Four printers fill the same six slots, which is the check that the abstraction is real rather than a bag of leftovers. +- The security-relevant escaping (`*/` in JSDoc, `<`/`>` in code strings, quoting in every language) is applied by construction instead of being a rule each generator author must have read. +- Python and Go gain a defined string-escaping policy where they had none. +- Ejected generators still read as the language they emit: `class`, `@dataclass`, `ClassVar[Dict[str, str]]` remain literal text in the file the user owns. +- **Three behavior changes move output bytes** — a real `string()` for Python and Go (47 call sites), the stricter merged TypeScript escaper, and unifying the two pagination resolvers. + All three are in scope for this rewrite rather than deferred, since the package is experimental ([ADR-0013](./0013-experimental-status.md)) and each fixes a defect rather than merely relocating code. + Each lands with its own tests and snapshot updates, so a byte change is reviewed as a behavior change and not lost inside a large move. +- **Cost: no automatic line-width breaking.** Long union types, signatures, and argument lists stay hand-wrapped. If that becomes a real complaint, the surgical fix is a `wrap()` helper for the few constructs that run long, not a change in how code is represented. +- ADR-0001's warning survives its decision and still applies: the printer is not a sanitizer. Names are coerced in the IR (`intermediate-representation/sanitize-identifiers.ts`) and comment text is escaped by `doc()`. Any new value flowing into an identifier slot or a comment needs the same handling. diff --git a/packages/client-generator/docs/adr/0022-runtime-inline-or-module.md b/packages/client-generator/docs/adr/0022-runtime-inline-or-module.md new file mode 100644 index 0000000000..36987170d5 --- /dev/null +++ b/packages/client-generator/docs/adr/0022-runtime-inline-or-module.md @@ -0,0 +1,53 @@ +# ADR 0022: Runtime distribution is inline or a sibling module; package mode is removed + +- Status: Accepted +- Date: 2026-08-21 +- Amends: [ADR-0017](./0017-runtime-module-and-descriptor-client.md) (point 3) + +## Context + +[ADR-0017](./0017-runtime-module-and-descriptor-client.md) made the runtime a hand-written module and offered two distributions: `inline` (default — the runtime embedded in the generated file, preserving [ADR-0002](./0002-typescript-peer-dep.md)'s zero-dependency promise) and `package` (the client imports `@redocly/client-generator`, so runtime fixes arrive by `npm update` with no regeneration). + +Making generators self-contained, ejectable folders ([ADR-0020](./0020-self-contained-generator-folders.md)) puts package mode in direct conflict with the rest of the architecture, in five places: + +1. **It contradicts the package's headline.** Package mode is the one mode in which the generated client has a dependency. +2. **It is the sole reason the root entry is constrained.** `entry-weight.test.ts` exists only because package-mode clients import the package root at app runtime — that is what forces the root free of `typescript`, `openapi-core`, and Node builtins, and forces `generateClient` to reach the pipeline through a dynamic import. +3. **It creates a silent-divergence trap.** Once a user ejects the generator and edits `runtime/retry.ts`, inline mode picks the change up and package mode does not — with no diagnostic. `PACKAGE_SPECIFIER` is a hardcoded const in `client-assembly.ts`, so their runtime is not reachable at all. +4. **It forces the TypeScript runtime to be dual-purpose** — both the text embedded into generated clients and the package's own exported runtime — which is the one thing blocking the runtime from living inside its generator's folder. +5. **It is an axis in the generator contract.** `runtimes?: ('inline' | 'package')[]` is declared per generator and checked by `validateGenerators`; php declares it does not support package mode. + +Package mode's real purpose is deduplication: do not inline about 1500 lines into every client. +That purpose does not require npm. + +A related finding is that inline mode, not module mode, is the one carrying machinery. +`assembleInlineRuntime` embeds `RUNTIME_SOURCES_STRIPPED` — modules with their syntax removed so they can concatenate into one file — and `pythonGenerator` strips `from __future__` lines and every intra-runtime `from ._x` import for the same reason. +A sibling `runtime/` folder needs none of that: the real sources are written as they are, imports intact. + +## Decision + +**`runtime` is `'inline' | 'module'`. Package mode is removed.** + +1. **`inline` stays the default** — one self-contained file with the runtime embedded, exactly as today. +2. **`module` writes the runtime as real files in a `runtime/` folder** beside the generated client, which imports it relatively. + Only the modules the API needs are written; the capability-seam assembly from [ADR-0017](./0017-runtime-module-and-descriptor-client.md) point 4 is unchanged, and the generated `createClient` factory becomes a file in that folder rather than a concatenated block. +3. **Both modes are available for every generator that embeds a runtime** — `typescript`, `python`, `go`, `php`, `cli`. + Module mode is more idiomatic than inline for two of them: Python's runtime is naturally `_send.py`, `_auth.py`, …, and Go packages span files by design. +4. **The runtime moves into its generator's folder** — `generators/typescript/runtime/*.ts`, `generators/python/runtime/*.py`, `generators/go/runtime/runtime.go`, `generators/php/runtime/runtime.php`, `generators/cli/runtime/cli.ts`. + Generators that embed no runtime (`zod`, `mock`, `swr`, `tanstack-query`, `transformers`) have no `runtime/` folder; `swr` and `tanstack-query` emit hooks that import the generated SDK module, so there is nothing for them to embed. +5. **The `runtimes` field leaves the generator contract**, along with the `--runtime package` CLI choice and its validation path. +6. **The root entry stops exporting the client runtime.** `createClient`, `ApiError`, `TimeoutError`, `mergeSetup`, `defaultRetryOn`, `runCli`, `invokedName`, and the runtime's type surface are removed — package mode was their reason for being public, and nothing imports the package root at app runtime any more. + The root keeps the authoring toolkit, the plugin API, the user-facing config types, and the setup contract. +7. **The setup contract moves up a layer.** `runtime-contract.ts` today re-exports `Middleware`, `RequestContext`, and `RetryConfig` *from* `runtime/types.ts`, deliberately, so a publisher's `--setup` file cannot drift from the generated output ([ADR-0015](./0015-publisher-setup-bake-in.md)). + With the runtime inside a generator folder, that direction would make the package root reach into `generators/typescript/`, so it inverts: the contract types are defined at package level and the TypeScript runtime imports them. + One definition either way — ownership moves from the runtime to the contract, which is the layer users actually author against. + +## Consequences + +- The self-contained folder structure becomes possible: no dual-purpose runtime, no re-export from the root into a generator folder, no top-level `runtime/` directory. +- The silent-divergence trap is gone. Whatever is in the user's `runtime/` folder **is** the runtime, in both modes. +- **`entry-weight.test.ts` is deleted, not relaxed.** With no app-runtime consumer of the package root, the rule it enforced — no `typescript`, no `openapi-core`, no Node builtins in the root's static graph — stops existing, and the dynamic `import('./pipeline.js')` inside `generateClient` is no longer forced by it. + The root entry becomes what it should have been: the authoring surface. +- Module mode gives package mode's deduplication without npm, without publishing, and while staying zero-dependency — and it needs no source stripping. +- **Cost: this is a breaking change for `runtime: 'package'` users**, and it withdraws the benefit ADR-0017 led with. Those users lose the `^`-range channel for runtime fixes and must regenerate instead — one command, but not nothing. The package is experimental at 0.x ([ADR-0013](./0013-experimental-status.md)) and the default was always `inline`, which bounds the blast radius; module mode is the migration path. +- **Cost: anyone importing the client runtime from the package root breaks.** That was package mode's surface, but it was public, and there is no deprecation window — the experimental status is doing the work here. +- The `--setup` contract keeps working: `bakeSetup` already strips the package import, so `defineClientSetup` and its types stay a compile-time-only surface ([ADR-0015](./0015-publisher-setup-bake-in.md)) — now defined at package level rather than re-exported from the runtime. diff --git a/packages/client-generator/docs/adr/README.md b/packages/client-generator/docs/adr/README.md index 9df55a34de..1aed6e58cd 100644 --- a/packages/client-generator/docs/adr/README.md +++ b/packages/client-generator/docs/adr/README.md @@ -11,7 +11,7 @@ ARCHITECTURE.md says _what is_; these ADRs say _why_. | # | Decision | Status | | ------------------------------------------------------ | ----------------------------------------------------------------- | ----------------------- | -| [0001](./0001-ast-codegen.md) | Generate TypeScript via the TS AST (`ts.factory`), not strings | Accepted | +| [0001](./0001-ast-codegen.md) | Generate TypeScript via the TS AST (`ts.factory`), not strings | Superseded by 0021 | | [0002](./0002-typescript-peer-dep.md) | `typescript` as a peer dep; zero-runtime-dependency output | Accepted | | [0003](./0003-spec-agnostic-ir.md) | A spec-agnostic IR as the builder↔emitter contract | Accepted | | [0004](./0004-registry-seams.md) | First-party `getGenerator` / `getWriter` registry seams | Accepted | @@ -27,9 +27,12 @@ ARCHITECTURE.md says _what is_; these ADRs say _why_. | [0014](./0014-request-response-customization.md) | Request/response customization as a runtime contract | Accepted | | [0015](./0015-publisher-setup-bake-in.md) | Publisher setup bake-in via `--setup` | Accepted | | [0016](./0016-msw-generator-vs-mock-server.md) | In-process MSW mocks coexist with the out-of-process mock server | Accepted | -| [0017](./0017-runtime-module-and-descriptor-client.md) | Hand-written runtime module + descriptor-driven generated clients | Accepted | +| [0017](./0017-runtime-module-and-descriptor-client.md) | Hand-written runtime module + descriptor-driven generated clients | Amended by 0022 | | [0018](./0018-auto-pagination.md) | Auto-pagination as declared, statically verified configuration | Accepted | | [0019](./0019-first-class-client-config.md) | `generate-client` config via a first-class `client` block | Accepted | +| [0020](./0020-self-contained-generator-folders.md) | Self-contained generator folders, ejected as source | Accepted | +| [0021](./0021-text-printers.md) | Text printers — one common printer plus one per language | Accepted | +| [0022](./0022-runtime-inline-or-module.md) | Runtime is inline or a sibling module; package mode removed | Accepted | ## Template diff --git a/packages/client-generator/docs/helper-surface.md b/packages/client-generator/docs/helper-surface.md new file mode 100644 index 0000000000..98547794e2 --- /dev/null +++ b/packages/client-generator/docs/helper-surface.md @@ -0,0 +1,235 @@ +# Helper surface — pre-rewrite analysis + +A complete inventory of the helper code in `@redocly/client-generator`, taken before the +generator-folder rewrite. +This says **what exists today, who uses it, and where it belongs** — so the rewrite moves +code with evidence rather than intuition. + +It is a point-in-time analysis, not a living document. +Once the rewrite lands, [`../ARCHITECTURE.md`](../ARCHITECTURE.md) is the descriptive map and this file can go. + +## Method + +Measurements below come from static analysis of `src/`, excluding `__tests__`: + +- **Reachability** — value imports (type-only imports are erased at runtime) followed transitively from each generator entry. +- **Direct symbol use** — `import { … } from …` bindings, attributed to the generator that owns the importing module. +- **Toolkit use** — identifier occurrences of each `AUTHORING_HELPER_NAMES` entry outside `authoring/`. + +Totals: **87 files, 15,913 lines, 183 exported values, 107 exported types.** + +Reachability overstates sharing (a module reached through three hops is not a shared helper), so +every claim below is based on direct symbol use. + +## Headline: there are two parallel toolkits + +`authoring/` is documented as "the language-neutral authoring toolkit — pure functions over the IR". +In practice it is **the toolkit the three non-TypeScript generators use.** +The TypeScript family has a complete shadow implementation in `emitters/`. + +| Concern | Neutral toolkit (`authoring/`) | TypeScript shadow (`emitters/`) | +| --- | --- | --- | +| Identifiers | `identifierFor`, `uniqueIdentifiers`, `RESERVED_WORDS` | `sanitizeIdentifier`, `uniqueIdent`, `safeIdent`, `isIdentifier`, `isSafeIdentifier`, `TS_RESERVED` | +| Text building | `Printer` | `[…].join('\n')` arrays | +| Description text | `docText` | `splitLines`, `jsdocText` | +| Comment escaping | — | `escapeJsDoc` | +| Schema shape | `isNullable`, `unwrapNullable`, `flattenAllOf`, `enumValues`, `discriminatorCases` | inline in `ts-type.ts` | +| Pagination | `paginationRuleFor` | `resolveOperationPagination`, `resolveModelPagination` | +| Casing | `casing.pascal` | `pascalCase` | + +Consumers of each neutral helper, counted outside `authoring/`: + +| Helper | Consumers | +| --- | --- | +| `NotSupportedError` | 9 — package-wide error type, genuinely shared | +| `Printer` | go, php, python, `cli-docs` (Markdown) | +| `renderReferencePage` | go, php, python, **typescript** | +| `schemaAtPointer` | go, php, python, `pagination` | +| `headerCoerceType` | go, php, python, `response-headers` | +| `casing` | go, `cli`, `runtime/cli`, `runtime-sources` | +| `identifierFor` | go, php, python | +| `uniqueIdentifiers` | go, php, python | +| `RESERVED_WORDS` | go, php, python | +| `flattenAllOf` | go, php, python | +| `discriminatorCases` | go, php, python | +| `isNullable` | go, php, python | +| `unwrapNullable` | go, php, python | +| `enumValues` | go, php, python | +| `docText` | go, php, python | +| `paginationRuleFor` | go, php, python | + +**Ten of sixteen neutral helpers have exactly three consumers, and they are always the same three.** +No TypeScript-family generator uses `Printer`, `docText`, `identifierFor`, or any of the schema-shape +helpers. +The neutral toolkit is not neutral in practice — it is the non-TypeScript toolkit, and TypeScript +duplicates it. + +## What TypeScript generators actually share with each other + +Measured at symbol level across all seven TypeScript-family generators (`typescript`, `zod`, `mock`, +`swr`, `tanstack-query`, `transformers`, `cli`), excluding each generator's own modules. + +**Genuinely TypeScript-specific and shared — four functions, 27 lines:** + +| Symbol | Module | Lines | Used by | +| --- | --- | --- | --- | +| `safeIdent` | `identifier.ts` | 6 | mock, tanstack-query, transformers, zod, typescript | +| `pascalCase` | `support.ts` | 3 | mock, swr, transformers, zod, typescript | +| `codeLiteral` | `ts-literal.ts` | 13 | typescript, mock, zod | +| `codeString` | `identifier.ts` | 5 | typescript, tanstack-query | + +**Shared but not TypeScript-specific** — the generator contract and output plumbing: +`Generator` (7×), `anchor` (7×), `HEADER` (7×), `CodeSample`/`SampleContext` (2×), `DateType` (2×). + +**Shared but IR analysis, misfiled into `emitters/`:** +`isSseOp` (3×), `resolveModelPagination` (2×), `PaginationConfig` (2×). + +**Used by the `typescript` generator alone** — the "shared TypeScript emitter layer" is largely a +myth; this is one generator's body living in a shared directory: +`tsType`, `tsJsdoc`, `renderTypeAliases`, `operationSignature`, `templatePathParams`, `descriptor`, +`type-guards`, `reserved-names`, `response-headers`, `inline-runtime`, `runtime-sources`, +`render-client`, `client-assembly`. + +**Cross-generator edges — exactly two in the entire package:** +`cli → typescript` for `embedCliRuntime` and `flatInputShape`. +Both lie along the `requires: ['typescript']` edge `cli` already declares. + +**One two-generator cluster:** `wrapper-support.ts` (98 lines — `wrappableOperations`, `isQuery`, +`hasInputs`, `variablesName`, `sdkCallText`, `sdkNamedImportText`), used by `swr` and +`tanstack-query` only. +This is not incidental overlap: it is the **ABI of the generated TypeScript SDK**, derived from +`operationSignature`, which `typescript` owns. + +## Duplications and conflicts + +Twelve concrete defects, each verifiable in the current source. + +| # | Finding | Evidence | +| --- | --- | --- | +| 1 | **Two identifier systems.** `authoring/naming.ts` says so in its own header: *"TypeScript keeps its specialized sanitizer in emitters/identifier.ts; this is for the other output languages."* | `authoring/naming.ts:1-4` | +| 2 | **`TS_RESERVED` is duplicated.** Two 44-word lists that must be hand-synced. | `emitters/identifier.ts` vs `RESERVED_WORDS.typescript` | +| 3 | **Opposite reserved-word conventions.** `sanitizeIdentifier` prefixes (`_class`); `identifierFor` suffixes (`class_`). Same problem, two answers, split by language accidentally. | `identifier.ts:76`, `naming.ts:82` | +| 4 | **Two TypeScript string escapers with different security policies.** `codeString` escapes U+2028/U+2029; `sanitizeCodeString` also escapes `<`/`>` to stop a `` breakout. Which protection applies depends on which one the caller imported. | `identifier.ts:87`, `ts-literal.ts:21` | +| 5 | **Python and Go have no string escaper.** They call `JSON.stringify` inline — **19 sites in python, 28 in go** — relying on JSON escaping being close enough to Python and Go literal syntax. No policy, no test. | `python/index.ts`, `go/index.ts` | +| 6 | **Two pagination resolvers implementing the same three-source precedence.** `paginationRuleFor` (declaration-only) and `resolveOperationPagination` (verifies fit, reports errors). Python goes through one, TypeScript the other — **they can disagree about whether an operation paginates.** | `authoring/pagination.ts`, `emitters/pagination.ts` | +| 7 | **Four hand-rolled doc-comment writers**, each re-deriving real per-language subtleties. | `writeDocstring` (py), `writeDocComment` (go), `writeDocComment` (php), `renderTitleComment` (ts) | +| 8 | **TypeScript syntax inside a "neutral" const.** `HEADER` is a hardcoded `//` comment, which is why `pythonGenerator` hand-writes its own `#` header. | `emit-options.ts:13` | +| 9 | **Indent units passed at call sites.** `new Printer(' ')`, `new Printer('\t')` — invisible in review. | `python/index.ts:234`, `go/index.ts:172` | +| 10 | **`anchor` is a four-line `path.parse` wrapper** used by all seven TypeScript generators; python re-implements it as `pythonModulePath`. | `generators/anchor.ts`, `python/index.ts:806` | +| 11 | **ADR-0001 and ARCHITECTURE.md describe deleted code.** Both document `ts.factory` AST codegen via `emitters/ts.ts` and `emitters/package-client.ts`. Neither module exists; every generator emits text. `jsdoc.ts` still refers the reader to `ts.ts`'s helper. | `docs/adr/0001`, `ARCHITECTURE.md`, `jsdoc.ts:12` | +| 12 | **`flatInputShape` contains no TypeScript.** It takes `OperationModel` + `NamedSchemaModel[]`, counts names, returns a verdict. It is TypeScript-only because it lives in `render-client.ts`. Python, Go, and PHP each re-derive the same collision question via `uniqueIdentifiers(…, { taken: METHOD_ARG_SLOTS })`. | `render-client.ts:174`, `python/index.ts:509`, `go/index.ts:494`, `php/index.ts:550` | + +Findings 4, 5, and 6 are correctness or security issues, not tidiness. + +## Where each helper lands + +Five destinations. +The rule: **facts belong on the data, syntax belongs on the printer, shape belongs to the generator.** + +### 1. Neutral toolkit — `@redocly/client-generator` + +Language-agnostic analysis over the IR, plus the authoring contract. + +| Keep | Add (re-homed from `emitters/`) | +| --- | --- | +| `Printer` (structure only), `casing`, `identifierFor`, `uniqueIdentifiers`, `RESERVED_WORDS` | `inputNameCollisions` — the neutral half of `flatInputShape` | +| `flattenAllOf`, `discriminatorCases`, `isNullable`, `unwrapNullable`, `enumValues`, `schemaAtPointer`, `headerCoerceType` | — | +| `docText`, `renderReferencePage`, `NotSupportedError` | — | +| `Generator`, `GeneratorInput`, `CodeSample`, `SampleContext`, `DateType` | — | + +**Removed by becoming data rather than helpers:** + +| Helper | Becomes | +| --- | --- | +| `isSseOp`, `eventSchema`, `sseDataKind` | `op.sse?: { eventSchema?, dataKind }` — computed once by the IR builder | +| `paginationRuleFor` + `resolveModelPagination` + `resolveOperationPagination` | **one** resolver, run once by the pipeline → `input.pagination` | +| `anchor` | `input.output: { path, dir, stem, ext }` | +| `HEADER`, `banner`, `renderTitleComment` | `input.banner: string[]` (content) + `printer.doc()` (syntax) | + +### 2. Language printers — `@redocly/client-generator/printers/` + +Syntax mechanics with exactly one right answer. +The boundary: **the printer owns syntax, the generator owns shape.** +No `class()`, `func()`, `method()`, or `signature()` helpers — those stay template literals so the +emitted code remains visible to whoever edits the generator next. + +Common core: `typeName`, `memberName`, `identifier`, `identifiers`, `string`, `literal`, `comment`, +`doc`, plus a `layout(source)` pass and a baked-in `indentUnit`. + +| Printer | Absorbs | Language-specific extension | +| --- | --- | --- | +| `TypeScriptPrinter` | `pascalCase`, `safeIdent`, `uniqueIdent`, `sanitizeIdentifier`, `codeString` + `sanitizeCodeString` (merged on the stricter policy), `codeLiteral`, `escapeJsDoc`, `jsdocText`, `splitLines` | `key(name)` — bare-or-quoted object key. No other language has quotable keys. | +| `PythonPrinter` | `className`, `fieldName`, `pythonLiteral`, `writeDocstring`, `Printer(' ')` | `constName` (SCREAMING_SNAKE); `memberName` reports whether it renamed, for `_field_map` | +| `GoPrinter` | `exported` (incl. the `_`→`N` rule), `writeDocComment`, `Printer('\t')` | `layout` = `gofmtShape` + `alignGoColumns`; `packageName` validation | +| `PhpPrinter` | `className`, `propertyName`, `phpString`, `writeDocComment` | `doc` takes `tags` — PHP's `array`/`\Generator` erase element types | + +Two notes on the extensions. +Go's `exported` carries knowledge that must not be re-derived: `identifierFor` prefixes `_` for a +digit-leading name, and in Go a leading `_` means **unexported**, so `encoding/json` would silently +skip the field. +Go's `layout` cannot be done line-by-line — column padding depends on the widest member of a run of +adjacent lines, which is not known when the first line is emitted. + +### 3. Generator-owned — `src/generators//` + +Everything that decides output *shape*. +`emitters/` dissolves entirely: + +| Generator | Absorbs | +| --- | --- | +| `typescript` | `client-assembly`, `render-client`, `descriptor`, `type-guards`, `reserved-names`, `response-headers`, `operation-types`, `operations`, `inline-runtime`, `runtime-sources`, `ts-type`, `emit-options` | +| `zod` | `zod.ts` | +| `mock` | `mock.ts`, `mock-value.ts`, `faker.ts`, `sample.ts` | +| `cli` | `cli.ts`, `cli-docs.ts` | +| `swr` | `swr.ts` | +| `tanstack-query` | `tanstack-query.ts` | +| `transformers` | `transformers.ts` | + +### 4. Generator contracts — `@redocly/client-generator/contracts/` + +A generator's published output ABI, importable **only** along a declared `requires` edge. + +`contracts/typescript` exports `operationSignature`, `templatePathParams`, `variablesName`, +`hasInputs`, `isQuery`, `sdkCallText`, `sdkNamedImportText`, `wrappableOperations`, `flatInputShape` +— consumed by `swr`, `tanstack-query` (`requires: ['typescript']`), and `cli` +(`requires: ['typescript', 'zod']`). + +Duplicating `wrapper-support` into swr and tanstack-query would put the SDK's ABI in two places, +which is exactly the drift its own header says it exists to prevent. + +### 5. Deleted + +`emitters/setup-bake.ts` stays (reached via a dynamic import from `pipeline.ts`), but these go: + +- The duplicate `TS_RESERVED` list. +- One of the two TypeScript string escapers. +- One of the two pagination resolvers. +- `anchor.ts`, `sse.ts`, `support.ts`, `jsdoc.ts`, `identifier.ts`, `ts-literal.ts` as standalone modules. +- **The root entry's client-runtime exports** — `createClient`, `ApiError`, `TimeoutError`, + `mergeSetup`, `defaultRetryOn`, `runCli`, `invokedName`, and the runtime type surface + ([ADR-0022](./adr/0022-runtime-inline-or-module.md)). + The setup contract stays public and moves up a layer: it is defined at package level and the + TypeScript runtime imports it, inverting today's `runtime-contract.ts` → `runtime/types.ts` + direction so the root never reaches into a generator folder. +- **`entry-weight.test.ts`** — with no app-runtime consumer of the package root, the constraint it + guards stops existing. + +## Behavior changes in scope + +Three items change output bytes. +All three are in scope for the rewrite — the package is experimental, and each fixes a defect rather +than relocating code — but each lands with its own tests and snapshot updates so a byte change is +reviewed as a behavior change rather than disappearing inside a large move: + +1. **`string()` for Python and Go.** Defining a real escaping policy replaces 47 raw `JSON.stringify` + calls and will differ for some inputs (non-ASCII, U+2028/U+2029, Go rune escapes). +2. **Merging the two TypeScript escapers.** Adopting the stricter policy means `<`/`>` are escaped in + places that previously left them literal. +3. **Unifying the pagination resolvers.** Wherever the two disagree today, one language's output changes. + +## Stale documentation to fix alongside + +- **ADR-0001** documents `ts.factory` AST codegen. Superseded by the printer ADR. +- **ARCHITECTURE.md** describes `emitters/ts.ts`, `emitters/package-client.ts`, and a `getWriter` + pipeline seam. None exist. +- **`jsdoc.ts:11`** refers the reader to `ts.ts`'s `jsdoc` helper, which was deleted. From a58a6a4555994c18ec62e3d94091c561fb0a8803 Mon Sep 17 00:00:00 2001 From: Roman Hotsiy Date: Fri, 21 Aug 2026 14:47:05 +0800 Subject: [PATCH 02/35] docs(client-generator): state that the single-file generators are refactored too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit python, go, and php are already self-contained, so ADR-0020 could be read as leaving them alone — the split was mentioned only as a migration cost. Make it a decision: self-containment was never the goal on its own, and leaving a 953-line python and a 1169-line go whole would keep the asymmetry the ADR removes. Both docs now show the re-grouping is not a rewrite: the existing functions in all three generators sort into the same stages as they are. --- .../0020-self-contained-generator-folders.md | 17 +++++++++++------ .../client-generator/docs/helper-surface.md | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/client-generator/docs/adr/0020-self-contained-generator-folders.md b/packages/client-generator/docs/adr/0020-self-contained-generator-folders.md index 3446ae2eb5..ebf42f7924 100644 --- a/packages/client-generator/docs/adr/0020-self-contained-generator-folders.md +++ b/packages/client-generator/docs/adr/0020-self-contained-generator-folders.md @@ -30,19 +30,24 @@ The fear that self-contained generators would duplicate a large shared TypeScrip 1. **One skeleton for every language.** A generator folder is `AGENTS.md`, `index.ts` (`run`/`sample`/`docs`/`options`), and one file per pipeline stage: `naming`, `types`, `models`, `descriptor`, `operations`, `pagination`, `client`, plus `runtime/` where the generator embeds one. The skeleton is **descriptive, not prescriptive** — a language omits a stage it does not have (python has no `split`, zod has no `client`), and there are no empty placeholder files. An agent that has read `generators/python/` can navigate `generators/typescript/` without re-learning. -2. **`emitters/` is dissolved.** Each module moves to the generator that owns it, to a language printer ([ADR-0021](./0021-text-printers.md)), or to the neutral toolkit. -3. **Three import rules, enforced by a guard test.** A generator folder may import only its own files, `@redocly/client-generator`, `@redocly/client-generator/printers/`, `@redocly/client-generator/runtime-sources`, and the contract of a generator it `requires`. +2. **The single-file generators are refactored into the same shape — they are not grandfathered.** + `python`, `go`, and `php` are already self-contained, but self-containment was never the goal on its own: the uniform skeleton is what makes generators comparable, navigable, and reviewable. + A 953-line `python/index.ts` and a 1169-line `go/index.ts` are past the size anyone holds at once, and leaving them whole would keep exactly the asymmetry this ADR removes — one language you read as a folder, another you read by scrolling. + The refactor is a **re-grouping, not a rewrite**: python's existing functions already sort into the stages cleanly — `className`/`fieldName`/`operationIdents` into `naming`, `pythonType` into `types`, `writeDataclass`/`renderPythonModels`/`pydanticDiscriminators` into `models`, `securitySpecs`/`paginationSpec`/`envelopeHeaderSpecs` into `descriptor`, `writeMethod` into `operations`, `writePaginationWrappers` into `pagination`, `writePythonServers`/`writeClientClass` into `client`. + Go and PHP sort the same way. +3. **`emitters/` is dissolved.** Each module moves to the generator that owns it, to a language printer ([ADR-0021](./0021-text-printers.md)), or to the neutral toolkit. +4. **Three import rules, enforced by a guard test.** A generator folder may import only its own files, `@redocly/client-generator`, `@redocly/client-generator/printers/`, `@redocly/client-generator/runtime-sources`, and the contract of a generator it `requires`. No relative import may leave the folder. `language-dogfooding.test.ts` generalizes from three generators to all ten. -4. **Package specifiers in source, resolved by `paths`.** Source imports the same specifier the ejected file does; a tsconfig `paths` entry maps it to `src/` for typechecking. +5. **Package specifiers in source, resolved by `paths`.** Source imports the same specifier the ejected file does; a tsconfig `paths` entry maps it to `src/` for typechecking. The `replaceAll` rewrite is deleted, and the source/ejected import lines become byte-identical. -5. **Sharing has four tiers, and only four.** The neutral toolkit (IR analysis, contract types, `Printer`); the language printer (syntax); `runtime-sources`; and a required generator's published **contract**. +6. **Sharing has four tiers, and only four.** The neutral toolkit (IR analysis, contract types, `Printer`); the language printer (syntax); `runtime-sources`; and a required generator's published **contract**. `contracts/typescript` exports the generated SDK's ABI — `operationSignature`, `variablesName`, `sdkCallText`, `wrappableOperations`, `flatInputShape` — for the generators that declare `requires: ['typescript']`. A generator may never import another generator's internals. -6. **Eject copies the folder as `.ts`.** No esbuild, no bundling, no synthesized entry module, no import rewriting. +7. **Eject copies the folder as `.ts`.** No esbuild, no bundling, no synthesized entry module, no import rewriting. The descriptor default export is still appended from `BUILTIN_META`, which keeps `meta.ts`'s laziness intact. `--update` merges per file with the three-way merge already used for skills. -7. **Ejected `.ts` requires a Node floor check at the point of use.** Built-in generators compile to `lib/*.js` and are unaffected; only an ejected folder is TypeScript. +8. **Ejected `.ts` requires a Node floor check at the point of use.** Built-in generators compile to `lib/*.js` and are unaffected; only an ejected folder is TypeScript. The resolver checks the running Node version when an entry resolves to a `.ts` file and errors with the required version. ## Consequences diff --git a/packages/client-generator/docs/helper-surface.md b/packages/client-generator/docs/helper-surface.md index 98547794e2..eec82e6150 100644 --- a/packages/client-generator/docs/helper-surface.md +++ b/packages/client-generator/docs/helper-surface.md @@ -173,6 +173,23 @@ adjacent lines, which is not known when the first line is emitted. ### 3. Generator-owned — `src/generators//` Everything that decides output *shape*. + +This runs in both directions. +The TypeScript-family generators **gain** the modules that are theirs alone, as `emitters/` dissolves. +The single-file generators are **split** into the same stages rather than left whole — `python` +(953 lines), `go` (1169), and `php` (1092) are self-contained already, but the uniform skeleton is +what makes them comparable, and their existing functions re-group into it without rewriting: + +| Stage | python | go | php | +| --- | --- | --- | --- | +| `naming` | `className`, `fieldName`, `operationIdents` | `exported`, `goOperationIdents` | `className`, `propertyName`, `methodName` | +| `types` | `pythonType` | `goType` | `phpType`, `phpNullable`, `phpUnionType` | +| `models` | `writeDataclass`, `renderPythonModels`, `pydanticDiscriminators` | `writeStruct`, `renderGoModels` | `writeClass`, `renderPhpModels`, `hydration`, `serialization` | +| `descriptor` | `securitySpecs`, `paginationSpec`, `envelopeHeaderSpecs` | `goSecurityLiteral`, `goPaginationLiteral` | `phpSecurityLiteral`, `phpPaginationLiteral` | +| `operations` | `writeMethod` | `writeGoMethod` | `writePhpMethod`, `methodArgs`, `writeRequestSetup` | +| `pagination` | `writePaginationWrappers` | `writeGoPaginationWrappers` | `writePhpPaginationWrappers` | +| `client` | `writePythonServers`, `writeClientClass` | `writeGoServers` | `writeServers` | + `emitters/` dissolves entirely: | Generator | Absorbs | From 048de154a73459f1774afeda9088ede893a865b0 Mon Sep 17 00:00:00 2001 From: Albina Blazhko <46962291+AlbinaBlazhko17@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:30:35 +0300 Subject: [PATCH 03/35] chore: update snapshots (#3048) --- ...uts-passed-to-step-target-workflow-and-remapped.test.ts.snap | 2 +- .../inputs-passed-to-step-target-workflow.test.ts.snap | 2 +- .../__snapshots__/inputs-with-cli-and-env.test.ts.snap | 2 +- .../__snapshots__/mask-input-secrets.test.ts.snap | 2 +- .../__snapshots__/outputs-access-syntax-variations.test.ts.snap | 2 +- .../replacements/__snapshots__/replacements.test.ts.snap | 2 +- .../__snapshots__/reusable-components.test.ts.snap | 2 +- .../__snapshots__/reveal-masked-input-secrets.test.ts.snap | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/e2e/respect/inputs-passed-to-step-target-workflow-and-remapped/__snapshots__/inputs-passed-to-step-target-workflow-and-remapped.test.ts.snap b/tests/e2e/respect/inputs-passed-to-step-target-workflow-and-remapped/__snapshots__/inputs-passed-to-step-target-workflow-and-remapped.test.ts.snap index 7557180771..985f43e940 100644 --- a/tests/e2e/respect/inputs-passed-to-step-target-workflow-and-remapped/__snapshots__/inputs-passed-to-step-target-workflow-and-remapped.test.ts.snap +++ b/tests/e2e/respect/inputs-passed-to-step-target-workflow-and-remapped/__snapshots__/inputs-passed-to-step-target-workflow-and-remapped.test.ts.snap @@ -52,7 +52,7 @@ exports[`should pass inputs to step target workflow with additional input parame     ✓ success criteria check - $statusCode == 400     ✓ success criteria check - $[?@.title == 'Validation failed'] -    ✓ status code check - $statusCode in [201, 400, 401, 500] +    ✓ status code check - $statusCode in [201, 400, 500]     ✓ content-type check     ✓ schema check diff --git a/tests/e2e/respect/inputs-passed-to-step-target-workflow/__snapshots__/inputs-passed-to-step-target-workflow.test.ts.snap b/tests/e2e/respect/inputs-passed-to-step-target-workflow/__snapshots__/inputs-passed-to-step-target-workflow.test.ts.snap index 2d4e1c58a3..f085107dba 100644 --- a/tests/e2e/respect/inputs-passed-to-step-target-workflow/__snapshots__/inputs-passed-to-step-target-workflow.test.ts.snap +++ b/tests/e2e/respect/inputs-passed-to-step-target-workflow/__snapshots__/inputs-passed-to-step-target-workflow.test.ts.snap @@ -52,7 +52,7 @@ exports[`should pass inputs to step target workflow with additional input parame     ✓ success criteria check - $statusCode == 400     ✓ success criteria check - $[?@.title == 'Validation failed'] -    ✓ status code check - $statusCode in [201, 400, 401, 500] +    ✓ status code check - $statusCode in [201, 400, 500]     ✓ content-type check     ✓ schema check diff --git a/tests/e2e/respect/inputs-with-cli-and-env/__snapshots__/inputs-with-cli-and-env.test.ts.snap b/tests/e2e/respect/inputs-with-cli-and-env/__snapshots__/inputs-with-cli-and-env.test.ts.snap index 942fee0559..52b2c71ce7 100644 --- a/tests/e2e/respect/inputs-with-cli-and-env/__snapshots__/inputs-with-cli-and-env.test.ts.snap +++ b/tests/e2e/respect/inputs-with-cli-and-env/__snapshots__/inputs-with-cli-and-env.test.ts.snap @@ -147,7 +147,7 @@ exports[`should use inputs from CLI and env 1`] = `     ✓ success criteria check - $statusCode == 400     ✓ success criteria check - $[?@.title == 'Validation failed'] -    ✓ status code check - $statusCode in [201, 400, 401, 500] +    ✓ status code check - $statusCode in [201, 400, 500]     ✓ content-type check     ✓ schema check diff --git a/tests/e2e/respect/mask-input-secrets/__snapshots__/mask-input-secrets.test.ts.snap b/tests/e2e/respect/mask-input-secrets/__snapshots__/mask-input-secrets.test.ts.snap index c68a98e75b..84391238bb 100644 --- a/tests/e2e/respect/mask-input-secrets/__snapshots__/mask-input-secrets.test.ts.snap +++ b/tests/e2e/respect/mask-input-secrets/__snapshots__/mask-input-secrets.test.ts.snap @@ -118,7 +118,7 @@ exports[`should hide sensitive input values 1`] = `     ✓ success criteria check - $statusCode == 400     ✓ success criteria check - $[?@.title == 'Validation failed'] -    ✓ status code check - $statusCode in [201, 400, 401, 500] +    ✓ status code check - $statusCode in [201, 400, 500]     ✓ content-type check     ✓ schema check diff --git a/tests/e2e/respect/outputs-access-syntax-variations/__snapshots__/outputs-access-syntax-variations.test.ts.snap b/tests/e2e/respect/outputs-access-syntax-variations/__snapshots__/outputs-access-syntax-variations.test.ts.snap index 962f2a27c1..8e7d14c7af 100644 --- a/tests/e2e/respect/outputs-access-syntax-variations/__snapshots__/outputs-access-syntax-variations.test.ts.snap +++ b/tests/e2e/respect/outputs-access-syntax-variations/__snapshots__/outputs-access-syntax-variations.test.ts.snap @@ -142,7 +142,7 @@ exports[`should resolve outputs access syntax variations 1`] = `     ✓ success criteria check - $statusCode == 400     ✓ success criteria check - $workflows.get-menu-items.outputs.itemsCount == 0 -    ✓ status code check - $statusCode in [201, 400, 401, 500] +    ✓ status code check - $statusCode in [201, 400, 500]     ✓ content-type check     ✓ schema check diff --git a/tests/e2e/respect/replacements/__snapshots__/replacements.test.ts.snap b/tests/e2e/respect/replacements/__snapshots__/replacements.test.ts.snap index ccfd98774a..917d0d7fa0 100644 --- a/tests/e2e/respect/replacements/__snapshots__/replacements.test.ts.snap +++ b/tests/e2e/respect/replacements/__snapshots__/replacements.test.ts.snap @@ -76,7 +76,7 @@ exports[`should replace values in the request body 1`] = `       }     ✓ success criteria check - $statusCode == 400 -    ✓ status code check - $statusCode in [201, 400, 401, 500] +    ✓ status code check - $statusCode in [201, 400, 500]     ✓ content-type check     ✓ schema check diff --git a/tests/e2e/respect/reusable-components/__snapshots__/reusable-components.test.ts.snap b/tests/e2e/respect/reusable-components/__snapshots__/reusable-components.test.ts.snap index 0be0334633..9c53391e03 100644 --- a/tests/e2e/respect/reusable-components/__snapshots__/reusable-components.test.ts.snap +++ b/tests/e2e/respect/reusable-components/__snapshots__/reusable-components.test.ts.snap @@ -187,7 +187,7 @@ exports[`should use inputs from CLI and env to map with resolved refs 1`] = `       }     ✓ success criteria check - $statusCode == 400 -    ✓ status code check - $statusCode in [201, 400, 401, 500] +    ✓ status code check - $statusCode in [201, 400, 500]     ✓ content-type check     ✓ schema check diff --git a/tests/e2e/respect/reveal-masked-input-secrets/__snapshots__/reveal-masked-input-secrets.test.ts.snap b/tests/e2e/respect/reveal-masked-input-secrets/__snapshots__/reveal-masked-input-secrets.test.ts.snap index 47f55e94a6..e00c894c2a 100644 --- a/tests/e2e/respect/reveal-masked-input-secrets/__snapshots__/reveal-masked-input-secrets.test.ts.snap +++ b/tests/e2e/respect/reveal-masked-input-secrets/__snapshots__/reveal-masked-input-secrets.test.ts.snap @@ -118,7 +118,7 @@ exports[`should reveal masked input values 1`] = `     ✓ success criteria check - $statusCode == 400     ✓ success criteria check - $[?@.title == 'Validation failed'] -    ✓ status code check - $statusCode in [201, 400, 401, 500] +    ✓ status code check - $statusCode in [201, 400, 500]     ✓ content-type check     ✓ schema check From 62fdb42bf3de32ee1dc8b21a637231bb2aee2df1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:11:30 +0300 Subject: [PATCH 04/35] =?UTF-8?q?chore:=20=F0=9F=94=96=20release=20new=20v?= =?UTF-8?q?ersions=20(#3043)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/olive-donkeys-shave.md | 6 ------ .changeset/seven-waves-create.md | 6 ------ docs/@v2/changelog.md | 11 +++++++++++ package-lock.json | 18 +++++++++--------- packages/cli/CHANGELOG.md | 11 +++++++++++ packages/cli/package.json | 8 ++++---- packages/client-generator/CHANGELOG.md | 6 ++++++ packages/client-generator/package.json | 4 ++-- packages/core/CHANGELOG.md | 10 ++++++++++ packages/core/package.json | 2 +- packages/respect-core/CHANGELOG.md | 6 ++++++ packages/respect-core/package.json | 4 ++-- 12 files changed, 62 insertions(+), 30 deletions(-) delete mode 100644 .changeset/olive-donkeys-shave.md delete mode 100644 .changeset/seven-waves-create.md diff --git a/.changeset/olive-donkeys-shave.md b/.changeset/olive-donkeys-shave.md deleted file mode 100644 index 0abc20ff82..0000000000 --- a/.changeset/olive-donkeys-shave.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@redocly/openapi-core': patch -'@redocly/cli': patch ---- - -Fixed the `stats` command reporting wrong parameter count for AsyncAPI descriptions. diff --git a/.changeset/seven-waves-create.md b/.changeset/seven-waves-create.md deleted file mode 100644 index 707caa43be..0000000000 --- a/.changeset/seven-waves-create.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@redocly/openapi-core': minor -'@redocly/cli': minor ---- - -Added a Vendor Extensions metric to the `stats` command that reports how many distinct `x-` extensions a description file uses and how often each one occurs. diff --git a/docs/@v2/changelog.md b/docs/@v2/changelog.md index c1ccee9f03..3c10851013 100644 --- a/docs/@v2/changelog.md +++ b/docs/@v2/changelog.md @@ -7,6 +7,17 @@ toc: +## 2.47.0 (2026-08-21) + +### Minor Changes + +- Added a Vendor Extensions metric to the `stats` command that reports how many distinct `x-` extensions a description file uses and how often each one occurs. + +### Patch Changes + +- Fixed the `stats` command reporting wrong parameter count for AsyncAPI descriptions. +- Updated @redocly/openapi-core to v2.47.0. + ## 2.46.2 (2026-08-19) ### Patch Changes diff --git a/package-lock.json b/package-lock.json index 0adbe0a1ab..eb7af0108d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11034,7 +11034,7 @@ }, "packages/cli": { "name": "@redocly/cli", - "version": "2.46.2", + "version": "2.47.0", "license": "MIT", "bin": { "openapi": "bin/cli.js", @@ -11046,9 +11046,9 @@ "@opentelemetry/sdk-trace-node": "2.8.0", "@opentelemetry/semantic-conventions": "1.41.1", "@redocly/cli-otel": "0.3.5", - "@redocly/client-generator": "0.3.7", - "@redocly/openapi-core": "2.46.2", - "@redocly/respect-core": "2.46.2", + "@redocly/client-generator": "0.3.8", + "@redocly/openapi-core": "2.47.0", + "@redocly/respect-core": "2.47.0", "@types/cookie": "0.6.0", "@types/har-format": "^1.2.16", "@types/react": "^17.0.0 || ^18.2.21 || ^19.2.16", @@ -11081,10 +11081,10 @@ }, "packages/client-generator": { "name": "@redocly/client-generator", - "version": "0.3.7", + "version": "0.3.8", "license": "MIT", "dependencies": { - "@redocly/openapi-core": "2.46.2" + "@redocly/openapi-core": "2.47.0" }, "devDependencies": { "typescript": "6.0.2" @@ -11104,7 +11104,7 @@ }, "packages/core": { "name": "@redocly/openapi-core", - "version": "2.46.2", + "version": "2.47.0", "license": "MIT", "dependencies": { "@redocly/ajv": "^8.18.3", @@ -11184,13 +11184,13 @@ }, "packages/respect-core": { "name": "@redocly/respect-core", - "version": "2.46.2", + "version": "2.47.0", "license": "MIT", "dependencies": { "@faker-js/faker": "^7.6.0", "@noble/hashes": "^1.8.0", "@redocly/ajv": "^8.18.3", - "@redocly/openapi-core": "2.46.2", + "@redocly/openapi-core": "2.47.0", "ajv": "npm:@redocly/ajv@^8.18.3", "better-ajv-errors": "^2.0.3", "colorette": "^2.0.20", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 4c2b10ce71..e28806c1b5 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,16 @@ # @redocly/cli +## 2.47.0 + +### Minor Changes + +- Added a Vendor Extensions metric to the `stats` command that reports how many distinct `x-` extensions a description file uses and how often each one occurs. + +### Patch Changes + +- Fixed the `stats` command reporting wrong parameter count for AsyncAPI descriptions. +- Updated @redocly/openapi-core to v2.47.0. + ## 2.46.2 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 6d5538b90e..34feb1d6a3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@redocly/cli", - "version": "2.46.2", + "version": "2.47.0", "description": "", "license": "MIT", "bin": { @@ -45,9 +45,9 @@ "@opentelemetry/sdk-trace-node": "2.8.0", "@opentelemetry/semantic-conventions": "1.41.1", "@redocly/cli-otel": "0.3.5", - "@redocly/client-generator": "0.3.7", - "@redocly/openapi-core": "2.46.2", - "@redocly/respect-core": "2.46.2", + "@redocly/client-generator": "0.3.8", + "@redocly/openapi-core": "2.47.0", + "@redocly/respect-core": "2.47.0", "@types/cookie": "0.6.0", "@types/har-format": "^1.2.16", "@types/react": "^17.0.0 || ^18.2.21 || ^19.2.16", diff --git a/packages/client-generator/CHANGELOG.md b/packages/client-generator/CHANGELOG.md index 9bb3dcb765..19c78d8bf0 100644 --- a/packages/client-generator/CHANGELOG.md +++ b/packages/client-generator/CHANGELOG.md @@ -1,5 +1,11 @@ # @redocly/client-generator +## 0.3.8 + +### Patch Changes + +- Updated @redocly/openapi-core to v2.47.0. + ## 0.3.7 ### Patch Changes diff --git a/packages/client-generator/package.json b/packages/client-generator/package.json index e75fa20d0b..92d7591d1f 100644 --- a/packages/client-generator/package.json +++ b/packages/client-generator/package.json @@ -1,6 +1,6 @@ { "name": "@redocly/client-generator", - "version": "0.3.7", + "version": "0.3.8", "description": "Generate typed, zero-dependency TypeScript clients (fetch, auth, retries, middleware, SSE) from OpenAPI descriptions.", "type": "module", "types": "lib/index.d.ts", @@ -50,7 +50,7 @@ "Roman Marshevskyi (https://redocly.com/)" ], "dependencies": { - "@redocly/openapi-core": "2.46.2" + "@redocly/openapi-core": "2.47.0" }, "peerDependencies": { "typescript": ">=5.5.0" diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 670dc33d3a..4fe0cb56b6 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,15 @@ # @redocly/openapi-core +## 2.47.0 + +### Minor Changes + +- Added a Vendor Extensions metric to the `stats` command that reports how many distinct `x-` extensions a description file uses and how often each one occurs. + +### Patch Changes + +- Fixed the `stats` command reporting wrong parameter count for AsyncAPI descriptions. + ## 2.46.2 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index c389a84cb8..62e8e3fc07 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@redocly/openapi-core", - "version": "2.46.2", + "version": "2.47.0", "description": "", "type": "module", "types": "lib/index.d.ts", diff --git a/packages/respect-core/CHANGELOG.md b/packages/respect-core/CHANGELOG.md index 3710e81da1..289794c708 100644 --- a/packages/respect-core/CHANGELOG.md +++ b/packages/respect-core/CHANGELOG.md @@ -1,5 +1,11 @@ # @redocly/respect-core +## 2.47.0 + +### Patch Changes + +- Updated @redocly/openapi-core to v2.47.0. + ## 2.46.2 ### Patch Changes diff --git a/packages/respect-core/package.json b/packages/respect-core/package.json index befef72066..096ebca5db 100644 --- a/packages/respect-core/package.json +++ b/packages/respect-core/package.json @@ -1,6 +1,6 @@ { "name": "@redocly/respect-core", - "version": "2.46.2", + "version": "2.47.0", "description": "API testing framework core", "type": "module", "types": "lib/index.d.ts", @@ -48,7 +48,7 @@ "@faker-js/faker": "^7.6.0", "@noble/hashes": "^1.8.0", "@redocly/ajv": "^8.18.3", - "@redocly/openapi-core": "2.46.2", + "@redocly/openapi-core": "2.47.0", "ajv": "npm:@redocly/ajv@^8.18.3", "better-ajv-errors": "^2.0.3", "colorette": "^2.0.20", From 03cb486aad6ae9ac9df4465214283d5149a9019d Mon Sep 17 00:00:00 2001 From: Vadym Vasylyshyn <51933329+vadyvas@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:32:00 +0300 Subject: [PATCH 05/35] fix: resolve $refs and validate AsyncAPI 3 Multi Format Schema Object (#3045) --- .changeset/ninety-phones-bow.md | 6 ++ .../__snapshots__/redocly-yaml.test.ts.snap | 1 + packages/core/src/types/asyncapi3.ts | 49 ++++++------- .../MessageHeaders.yaml | 5 ++ .../UserSignedUp.yaml | 9 +++ .../async3-multi-format-schema/asyncapi.yaml | 50 ++++++++++++++ .../async3-multi-format-schema/redocly.yaml | 3 + .../async3-multi-format-schema/snapshot.txt | 69 +++++++++++++++++++ 8 files changed, 168 insertions(+), 24 deletions(-) create mode 100644 .changeset/ninety-phones-bow.md create mode 100644 tests/e2e/bundle/async3-multi-format-schema/MessageHeaders.yaml create mode 100644 tests/e2e/bundle/async3-multi-format-schema/UserSignedUp.yaml create mode 100644 tests/e2e/bundle/async3-multi-format-schema/asyncapi.yaml create mode 100644 tests/e2e/bundle/async3-multi-format-schema/redocly.yaml create mode 100644 tests/e2e/bundle/async3-multi-format-schema/snapshot.txt diff --git a/.changeset/ninety-phones-bow.md b/.changeset/ninety-phones-bow.md new file mode 100644 index 0000000000..4dbe9d5504 --- /dev/null +++ b/.changeset/ninety-phones-bow.md @@ -0,0 +1,6 @@ +--- +'@redocly/openapi-core': patch +'@redocly/cli': patch +--- + +Fixed an issue where the `bundle` command didn't resolve `$ref`s inside an AsyncAPI 3 Multi Format Schema Object. diff --git a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap index 61d9e0c6fe..0300084219 100644 --- a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap @@ -1524,6 +1524,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "Ros2OperationBinding", "Ros2QosPolicies", "Ros2MessageBinding", + "MultiFormatSchema", "OperationReply", "OperationReplyAddress", "NamedTags", diff --git a/packages/core/src/types/asyncapi3.ts b/packages/core/src/types/asyncapi3.ts index 038cf740f8..5e77fe6d04 100644 --- a/packages/core/src/types/asyncapi3.ts +++ b/packages/core/src/types/asyncapi3.ts @@ -182,19 +182,11 @@ const Parameter: NodeType = { const Message: NodeType = { extensionsPrefix: 'x-', properties: { - headers: 'Schema', - payload: (value: Record) => { - if (!!value && value?.['schemaFormat']) { - return { - properties: { - schema: 'Schema', - schemaFormat: { type: 'string' }, - }, - required: ['schema', 'schemaFormat'], - }; - } else { - return 'Schema'; - } + headers: (value: unknown) => { + return isPlainObject(value) && 'schema' in value ? 'MultiFormatSchema' : 'Schema'; + }, + payload: (value: unknown) => { + return isPlainObject(value) && 'schema' in value ? 'MultiFormatSchema' : 'Schema'; }, correlationId: 'CorrelationId', @@ -259,16 +251,7 @@ const MessageTrait: NodeType = { extensionsPrefix: 'x-', properties: { headers: (value: unknown) => { - if (typeof value === 'function' || isPlainObject(value)) { - return { - properties: { - schema: 'Schema', - schemaFormat: { type: 'string' }, - }, - }; - } else { - return 'Schema'; - } + return isPlainObject(value) && 'schema' in value ? 'MultiFormatSchema' : 'Schema'; }, correlationId: 'CorrelationId', @@ -564,6 +547,23 @@ const MessageBindings: NodeType = { }, }; +const MultiFormatSchema: NodeType = { + extensionsPrefix: 'x-', + properties: { + schema: 'Schema', + schemaFormat: { type: 'string' }, + }, + description: + 'Represents a schema definition. Unlike the Schema Object, it supports multiple schema formats or languages.', +}; + +const NamedSchemas: NodeType = { + properties: {}, + additionalProperties: (value: unknown) => { + return isPlainObject(value) && 'schema' in value ? 'MultiFormatSchema' : 'Schema'; + }, +}; + export const AsyncApi3Types: Record = { ...AsyncApiBindings, ...Ros2Bindings, @@ -580,6 +580,7 @@ export const AsyncApi3Types: Record = { Tag, Dependencies, Schema, + MultiFormatSchema, Discriminator, DiscriminatorMapping, SchemaProperties, @@ -610,7 +611,7 @@ export const AsyncApi3Types: Record = { NamedOperations: mapOf('Operation'), NamedOperationReplies: mapOf('OperationReply'), NamedOperationRelyAddresses: mapOf('OperationReplyAddress'), - NamedSchemas: mapOf('Schema'), + NamedSchemas, NamedMessages: mapOf('Message'), NamedMessageTraits: mapOf('MessageTrait'), NamedOperationTraits: mapOf('OperationTrait'), diff --git a/tests/e2e/bundle/async3-multi-format-schema/MessageHeaders.yaml b/tests/e2e/bundle/async3-multi-format-schema/MessageHeaders.yaml new file mode 100644 index 0000000000..f352ed50cb --- /dev/null +++ b/tests/e2e/bundle/async3-multi-format-schema/MessageHeaders.yaml @@ -0,0 +1,5 @@ +type: object +properties: + correlationId: + type: string + description: Correlation ID set by the producer diff --git a/tests/e2e/bundle/async3-multi-format-schema/UserSignedUp.yaml b/tests/e2e/bundle/async3-multi-format-schema/UserSignedUp.yaml new file mode 100644 index 0000000000..1b272683c6 --- /dev/null +++ b/tests/e2e/bundle/async3-multi-format-schema/UserSignedUp.yaml @@ -0,0 +1,9 @@ +type: object +properties: + displayName: + type: string + description: Name of the user + email: + type: string + format: email + description: Email of the user diff --git a/tests/e2e/bundle/async3-multi-format-schema/asyncapi.yaml b/tests/e2e/bundle/async3-multi-format-schema/asyncapi.yaml new file mode 100644 index 0000000000..ffca5628d1 --- /dev/null +++ b/tests/e2e/bundle/async3-multi-format-schema/asyncapi.yaml @@ -0,0 +1,50 @@ +asyncapi: 3.0.0 +info: + title: Account Service + version: 1.0.0 + description: This service is in charge of processing user signups +channels: + userSignedup: + address: user/signedup + messages: + UserSignedUpMultiFormat: + title: Multi-format headers and payload + headers: + schemaFormat: 'application/vnd.aai.asyncapi+yaml;version=3.0.0' + schema: + $ref: ./MessageHeaders.yaml + payload: + schemaFormat: 'application/vnd.aai.asyncapi+yaml;version=3.0.0' + schema: + $ref: ./UserSignedUp.yaml + traits: + - headers: + schemaFormat: 'application/vnd.aai.asyncapi+yaml;version=3.0.0' + schema: + $ref: ./MessageHeaders.yaml + UserSignedUpWithoutSchemaFormat: + title: Multi-format payload relying on the default schemaFormat + payload: + schema: + $ref: ./UserSignedUp.yaml + UserSignedUpPlain: + title: Plain Schema Object headers and payload + headers: + $ref: ./MessageHeaders.yaml + payload: + $ref: ./UserSignedUp.yaml +operations: + sendUserSignedup: + action: send + channel: + $ref: '#/channels/userSignedup' + messages: + - $ref: '#/channels/userSignedup/messages/UserSignedUpMultiFormat' + - $ref: '#/channels/userSignedup/messages/UserSignedUpWithoutSchemaFormat' + - $ref: '#/channels/userSignedup/messages/UserSignedUpPlain' +components: + schemas: + ReusableMultiFormat: + schemaFormat: 'application/vnd.aai.asyncapi+yaml;version=3.0.0' + schema: + $ref: ./UserSignedUp.yaml diff --git a/tests/e2e/bundle/async3-multi-format-schema/redocly.yaml b/tests/e2e/bundle/async3-multi-format-schema/redocly.yaml new file mode 100644 index 0000000000..85e1946424 --- /dev/null +++ b/tests/e2e/bundle/async3-multi-format-schema/redocly.yaml @@ -0,0 +1,3 @@ +apis: + main: + root: ./asyncapi.yaml diff --git a/tests/e2e/bundle/async3-multi-format-schema/snapshot.txt b/tests/e2e/bundle/async3-multi-format-schema/snapshot.txt new file mode 100644 index 0000000000..b220d3f0cd --- /dev/null +++ b/tests/e2e/bundle/async3-multi-format-schema/snapshot.txt @@ -0,0 +1,69 @@ +asyncapi: 3.0.0 +info: + title: Account Service + version: 1.0.0 + description: This service is in charge of processing user signups +channels: + userSignedup: + address: user/signedup + messages: + UserSignedUpMultiFormat: + title: Multi-format headers and payload + headers: + schemaFormat: application/vnd.aai.asyncapi+yaml;version=3.0.0 + schema: + $ref: '#/components/schemas/MessageHeaders' + payload: + schemaFormat: application/vnd.aai.asyncapi+yaml;version=3.0.0 + schema: + $ref: '#/components/schemas/UserSignedUp' + traits: + - headers: + schemaFormat: application/vnd.aai.asyncapi+yaml;version=3.0.0 + schema: + $ref: '#/components/schemas/MessageHeaders' + UserSignedUpWithoutSchemaFormat: + title: Multi-format payload relying on the default schemaFormat + payload: + schema: + $ref: '#/components/schemas/UserSignedUp' + UserSignedUpPlain: + title: Plain Schema Object headers and payload + headers: + $ref: '#/components/schemas/MessageHeaders' + payload: + $ref: '#/components/schemas/UserSignedUp' +operations: + sendUserSignedup: + action: send + channel: + $ref: '#/channels/userSignedup' + messages: + - $ref: '#/channels/userSignedup/messages/UserSignedUpMultiFormat' + - $ref: '#/channels/userSignedup/messages/UserSignedUpWithoutSchemaFormat' + - $ref: '#/channels/userSignedup/messages/UserSignedUpPlain' +components: + schemas: + ReusableMultiFormat: + schemaFormat: application/vnd.aai.asyncapi+yaml;version=3.0.0 + schema: + $ref: '#/components/schemas/UserSignedUp' + MessageHeaders: + type: object + properties: + correlationId: + type: string + description: Correlation ID set by the producer + UserSignedUp: + type: object + properties: + displayName: + type: string + description: Name of the user + email: + type: string + format: email + description: Email of the user + +bundling asyncapi.yaml using configuration for api 'main'... +📦 Created a bundle for asyncapi.yaml at stdout ms. From 73935f96426b718cd4a64524b362e0beda68bd58 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 17:00:07 +0300 Subject: [PATCH 06/35] fix(client-generator): the live defects the generator review found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each reproduced before it was fixed, each with a regression test. An enum value of `-1` produced `VALUE_-1 = -1` — a SyntaxError that made the whole generated Python module unusable. `enumValues` now routes every value through `casing` (which already knew `MINUS_1`) and `uniqueIdentifiers`, so decimals, values that fold to one name, and the empty string all stay usable. Go and PHP built their own member names with the same folding flaw (`1.5` and `15` both became `15`) and dedupe per enum now, with plain integers keeping their old spelling. PHP fataled on two operationIds that camel-case alike (`get-user`, `getUser` → `Cannot redeclare Client::getUser()`). It now derives one deduped name per operation, like Python and Go already did. The sample hooks in all three languages used the raw name, so on a collision the snippet called a method that goes to a different operation — they read the deduped map now, and `goSample` also matches its assignment to the return shape (`err :=` for void, `stream :=` for SSE, where `result, err :=` does not compile). A Go array query param went through `fmt.Sprint`, putting `?tags=[a b]` on the wire as one value. An array now repeats the key per element — proven with a real `httptest` server: `tags=a&tags=b`. `register()` copied seven fields and dropped `docs` and `notApplicable`, so an ejected generator quietly did less than the built-in it replaced: `--docs` wrote no page, ignored options stopped warning. Two dropped-`dateType` call sites: PHP's top-level hydration returned a raw string where its own signature declared `\DateTimeImmutable`, and Python's iterator signatures said `str` where the method beside them said `datetime`. The defaulted parameter was the trap, so PHP's `hydration` now requires it. --- .../docs/adr/0021-text-printers.md | 4 +- .../docs/adr/0022-runtime-inline-or-module.md | 2 +- .../client-generator/docs/helper-surface.md | 168 +++++++++--------- .../src/authoring/__tests__/schema.test.ts | 14 ++ .../client-generator/src/authoring/schema.ts | 14 +- .../src/generators/__tests__/go.test.ts | 25 ++- .../src/generators/__tests__/php.test.ts | 130 +++++++++++++- .../src/generators/__tests__/python.test.ts | 66 +++++++ .../src/generators/__tests__/resolve.test.ts | 17 ++ .../src/generators/go/index.ts | 49 ++++- .../src/generators/php/index.ts | 48 +++-- .../src/generators/python/index.ts | 8 +- .../src/generators/resolve.ts | 5 + 13 files changed, 439 insertions(+), 111 deletions(-) diff --git a/packages/client-generator/docs/adr/0021-text-printers.md b/packages/client-generator/docs/adr/0021-text-printers.md index d1ad3d10b6..858cab161e 100644 --- a/packages/client-generator/docs/adr/0021-text-printers.md +++ b/packages/client-generator/docs/adr/0021-text-printers.md @@ -12,7 +12,7 @@ This ADR records the shape the code actually has, and settles what belongs in a The text layer today is inconsistent in ways that are more than cosmetic — the full inventory is in [`../helper-surface.md`](../helper-surface.md), and the load-bearing findings are: -- **Two identifier systems.** `authoring/naming.ts` states it in its own header: *"TypeScript keeps its specialized sanitizer in emitters/identifier.ts; this is for the other output languages."* The TypeScript reserved-word list exists twice, and the two systems disagree on convention — `sanitizeIdentifier` prefixes (`_class`), `identifierFor` suffixes (`class_`). +- **Two identifier systems.** `authoring/naming.ts` states it in its own header: _"TypeScript keeps its specialized sanitizer in emitters/identifier.ts; this is for the other output languages."_ The TypeScript reserved-word list exists twice, and the two systems disagree on convention — `sanitizeIdentifier` prefixes (`_class`), `identifierFor` suffixes (`class_`). - **Two TypeScript string escapers with different security policies.** `codeString` escapes U+2028/U+2029; `sanitizeCodeString` also escapes `<`/`>` to prevent a `` breakout. Which protection applies depends on which one the caller imported. - **Python and Go have no string escaper at all** — 19 and 28 raw `JSON.stringify` calls respectively, relying on JSON escaping being close enough to each language's literal syntax. - **Four hand-rolled doc-comment writers**, each re-deriving real per-language rules: Go collapses consecutive blank comment lines because gofmt rewrites `//\n//`; TypeScript must escape `*/` because `info.title` is attacker-controllable; Python has distinct one-line and multi-line docstring forms; PHP needs `@tag` lines because its type syntax erases element types. @@ -21,7 +21,7 @@ The text layer today is inconsistent in ways that are more than cosmetic — the Two alternatives were considered and rejected. **Prettier's Doc IR** (the Wadler/Oppen algebra behind `group`/`line`/`indent`) would buy automatic line-width breaking, which is a genuine gap — generated output is hand-formatted with no post-pass. -It was rejected because `group([indent([line, …])])` hides the emitted text, and Prettier's own architecture argues against it here: Prettier has no universal syntax model either, only a universal *layout* engine plus a hand-written printer per language. +It was rejected because `group([indent([line, …])])` hides the emitted text, and Prettier's own architecture argues against it here: Prettier has no universal syntax model either, only a universal _layout_ engine plus a hand-written printer per language. Its printers run to thousands of lines because they must handle every possible program; ours emit roughly fifteen constructs per language. We do not have Prettier's problem. diff --git a/packages/client-generator/docs/adr/0022-runtime-inline-or-module.md b/packages/client-generator/docs/adr/0022-runtime-inline-or-module.md index 36987170d5..282ee50ea0 100644 --- a/packages/client-generator/docs/adr/0022-runtime-inline-or-module.md +++ b/packages/client-generator/docs/adr/0022-runtime-inline-or-module.md @@ -37,7 +37,7 @@ A sibling `runtime/` folder needs none of that: the real sources are written as 5. **The `runtimes` field leaves the generator contract**, along with the `--runtime package` CLI choice and its validation path. 6. **The root entry stops exporting the client runtime.** `createClient`, `ApiError`, `TimeoutError`, `mergeSetup`, `defaultRetryOn`, `runCli`, `invokedName`, and the runtime's type surface are removed — package mode was their reason for being public, and nothing imports the package root at app runtime any more. The root keeps the authoring toolkit, the plugin API, the user-facing config types, and the setup contract. -7. **The setup contract moves up a layer.** `runtime-contract.ts` today re-exports `Middleware`, `RequestContext`, and `RetryConfig` *from* `runtime/types.ts`, deliberately, so a publisher's `--setup` file cannot drift from the generated output ([ADR-0015](./0015-publisher-setup-bake-in.md)). +7. **The setup contract moves up a layer.** `runtime-contract.ts` today re-exports `Middleware`, `RequestContext`, and `RetryConfig` _from_ `runtime/types.ts`, deliberately, so a publisher's `--setup` file cannot drift from the generated output ([ADR-0015](./0015-publisher-setup-bake-in.md)). With the runtime inside a generator folder, that direction would make the package root reach into `generators/typescript/`, so it inverts: the contract types are defined at package level and the TypeScript runtime imports them. One definition either way — ownership moves from the runtime to the contract, which is the layer users actually author against. diff --git a/packages/client-generator/docs/helper-surface.md b/packages/client-generator/docs/helper-surface.md index eec82e6150..adb36d0451 100644 --- a/packages/client-generator/docs/helper-surface.md +++ b/packages/client-generator/docs/helper-surface.md @@ -27,36 +27,36 @@ every claim below is based on direct symbol use. In practice it is **the toolkit the three non-TypeScript generators use.** The TypeScript family has a complete shadow implementation in `emitters/`. -| Concern | Neutral toolkit (`authoring/`) | TypeScript shadow (`emitters/`) | -| --- | --- | --- | -| Identifiers | `identifierFor`, `uniqueIdentifiers`, `RESERVED_WORDS` | `sanitizeIdentifier`, `uniqueIdent`, `safeIdent`, `isIdentifier`, `isSafeIdentifier`, `TS_RESERVED` | -| Text building | `Printer` | `[…].join('\n')` arrays | -| Description text | `docText` | `splitLines`, `jsdocText` | -| Comment escaping | — | `escapeJsDoc` | -| Schema shape | `isNullable`, `unwrapNullable`, `flattenAllOf`, `enumValues`, `discriminatorCases` | inline in `ts-type.ts` | -| Pagination | `paginationRuleFor` | `resolveOperationPagination`, `resolveModelPagination` | -| Casing | `casing.pascal` | `pascalCase` | +| Concern | Neutral toolkit (`authoring/`) | TypeScript shadow (`emitters/`) | +| ---------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Identifiers | `identifierFor`, `uniqueIdentifiers`, `RESERVED_WORDS` | `sanitizeIdentifier`, `uniqueIdent`, `safeIdent`, `isIdentifier`, `isSafeIdentifier`, `TS_RESERVED` | +| Text building | `Printer` | `[…].join('\n')` arrays | +| Description text | `docText` | `splitLines`, `jsdocText` | +| Comment escaping | — | `escapeJsDoc` | +| Schema shape | `isNullable`, `unwrapNullable`, `flattenAllOf`, `enumValues`, `discriminatorCases` | inline in `ts-type.ts` | +| Pagination | `paginationRuleFor` | `resolveOperationPagination`, `resolveModelPagination` | +| Casing | `casing.pascal` | `pascalCase` | Consumers of each neutral helper, counted outside `authoring/`: -| Helper | Consumers | -| --- | --- | -| `NotSupportedError` | 9 — package-wide error type, genuinely shared | -| `Printer` | go, php, python, `cli-docs` (Markdown) | -| `renderReferencePage` | go, php, python, **typescript** | -| `schemaAtPointer` | go, php, python, `pagination` | -| `headerCoerceType` | go, php, python, `response-headers` | -| `casing` | go, `cli`, `runtime/cli`, `runtime-sources` | -| `identifierFor` | go, php, python | -| `uniqueIdentifiers` | go, php, python | -| `RESERVED_WORDS` | go, php, python | -| `flattenAllOf` | go, php, python | -| `discriminatorCases` | go, php, python | -| `isNullable` | go, php, python | -| `unwrapNullable` | go, php, python | -| `enumValues` | go, php, python | -| `docText` | go, php, python | -| `paginationRuleFor` | go, php, python | +| Helper | Consumers | +| --------------------- | --------------------------------------------- | +| `NotSupportedError` | 9 — package-wide error type, genuinely shared | +| `Printer` | go, php, python, `cli-docs` (Markdown) | +| `renderReferencePage` | go, php, python, **typescript** | +| `schemaAtPointer` | go, php, python, `pagination` | +| `headerCoerceType` | go, php, python, `response-headers` | +| `casing` | go, `cli`, `runtime/cli`, `runtime-sources` | +| `identifierFor` | go, php, python | +| `uniqueIdentifiers` | go, php, python | +| `RESERVED_WORDS` | go, php, python | +| `flattenAllOf` | go, php, python | +| `discriminatorCases` | go, php, python | +| `isNullable` | go, php, python | +| `unwrapNullable` | go, php, python | +| `enumValues` | go, php, python | +| `docText` | go, php, python | +| `paginationRuleFor` | go, php, python | **Ten of sixteen neutral helpers have exactly three consumers, and they are always the same three.** No TypeScript-family generator uses `Printer`, `docText`, `identifierFor`, or any of the schema-shape @@ -71,12 +71,12 @@ Measured at symbol level across all seven TypeScript-family generators (`typescr **Genuinely TypeScript-specific and shared — four functions, 27 lines:** -| Symbol | Module | Lines | Used by | -| --- | --- | --- | --- | -| `safeIdent` | `identifier.ts` | 6 | mock, tanstack-query, transformers, zod, typescript | -| `pascalCase` | `support.ts` | 3 | mock, swr, transformers, zod, typescript | -| `codeLiteral` | `ts-literal.ts` | 13 | typescript, mock, zod | -| `codeString` | `identifier.ts` | 5 | typescript, tanstack-query | +| Symbol | Module | Lines | Used by | +| ------------- | --------------- | ----- | --------------------------------------------------- | +| `safeIdent` | `identifier.ts` | 6 | mock, tanstack-query, transformers, zod, typescript | +| `pascalCase` | `support.ts` | 3 | mock, swr, transformers, zod, typescript | +| `codeLiteral` | `ts-literal.ts` | 13 | typescript, mock, zod | +| `codeString` | `identifier.ts` | 5 | typescript, tanstack-query | **Shared but not TypeScript-specific** — the generator contract and output plumbing: `Generator` (7×), `anchor` (7×), `HEADER` (7×), `CodeSample`/`SampleContext` (2×), `DateType` (2×). @@ -104,20 +104,20 @@ This is not incidental overlap: it is the **ABI of the generated TypeScript SDK* Twelve concrete defects, each verifiable in the current source. -| # | Finding | Evidence | -| --- | --- | --- | -| 1 | **Two identifier systems.** `authoring/naming.ts` says so in its own header: *"TypeScript keeps its specialized sanitizer in emitters/identifier.ts; this is for the other output languages."* | `authoring/naming.ts:1-4` | -| 2 | **`TS_RESERVED` is duplicated.** Two 44-word lists that must be hand-synced. | `emitters/identifier.ts` vs `RESERVED_WORDS.typescript` | -| 3 | **Opposite reserved-word conventions.** `sanitizeIdentifier` prefixes (`_class`); `identifierFor` suffixes (`class_`). Same problem, two answers, split by language accidentally. | `identifier.ts:76`, `naming.ts:82` | -| 4 | **Two TypeScript string escapers with different security policies.** `codeString` escapes U+2028/U+2029; `sanitizeCodeString` also escapes `<`/`>` to stop a `` breakout. Which protection applies depends on which one the caller imported. | `identifier.ts:87`, `ts-literal.ts:21` | -| 5 | **Python and Go have no string escaper.** They call `JSON.stringify` inline — **19 sites in python, 28 in go** — relying on JSON escaping being close enough to Python and Go literal syntax. No policy, no test. | `python/index.ts`, `go/index.ts` | -| 6 | **Two pagination resolvers implementing the same three-source precedence.** `paginationRuleFor` (declaration-only) and `resolveOperationPagination` (verifies fit, reports errors). Python goes through one, TypeScript the other — **they can disagree about whether an operation paginates.** | `authoring/pagination.ts`, `emitters/pagination.ts` | -| 7 | **Four hand-rolled doc-comment writers**, each re-deriving real per-language subtleties. | `writeDocstring` (py), `writeDocComment` (go), `writeDocComment` (php), `renderTitleComment` (ts) | -| 8 | **TypeScript syntax inside a "neutral" const.** `HEADER` is a hardcoded `//` comment, which is why `pythonGenerator` hand-writes its own `#` header. | `emit-options.ts:13` | -| 9 | **Indent units passed at call sites.** `new Printer(' ')`, `new Printer('\t')` — invisible in review. | `python/index.ts:234`, `go/index.ts:172` | -| 10 | **`anchor` is a four-line `path.parse` wrapper** used by all seven TypeScript generators; python re-implements it as `pythonModulePath`. | `generators/anchor.ts`, `python/index.ts:806` | -| 11 | **ADR-0001 and ARCHITECTURE.md describe deleted code.** Both document `ts.factory` AST codegen via `emitters/ts.ts` and `emitters/package-client.ts`. Neither module exists; every generator emits text. `jsdoc.ts` still refers the reader to `ts.ts`'s helper. | `docs/adr/0001`, `ARCHITECTURE.md`, `jsdoc.ts:12` | -| 12 | **`flatInputShape` contains no TypeScript.** It takes `OperationModel` + `NamedSchemaModel[]`, counts names, returns a verdict. It is TypeScript-only because it lives in `render-client.ts`. Python, Go, and PHP each re-derive the same collision question via `uniqueIdentifiers(…, { taken: METHOD_ARG_SLOTS })`. | `render-client.ts:174`, `python/index.ts:509`, `go/index.ts:494`, `php/index.ts:550` | +| # | Finding | Evidence | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| 1 | **Two identifier systems.** `authoring/naming.ts` says so in its own header: _"TypeScript keeps its specialized sanitizer in emitters/identifier.ts; this is for the other output languages."_ | `authoring/naming.ts:1-4` | +| 2 | **`TS_RESERVED` is duplicated.** Two 44-word lists that must be hand-synced. | `emitters/identifier.ts` vs `RESERVED_WORDS.typescript` | +| 3 | **Opposite reserved-word conventions.** `sanitizeIdentifier` prefixes (`_class`); `identifierFor` suffixes (`class_`). Same problem, two answers, split by language accidentally. | `identifier.ts:76`, `naming.ts:82` | +| 4 | **Two TypeScript string escapers with different security policies.** `codeString` escapes U+2028/U+2029; `sanitizeCodeString` also escapes `<`/`>` to stop a `` breakout. Which protection applies depends on which one the caller imported. | `identifier.ts:87`, `ts-literal.ts:21` | +| 5 | **Python and Go have no string escaper.** They call `JSON.stringify` inline — **19 sites in python, 28 in go** — relying on JSON escaping being close enough to Python and Go literal syntax. No policy, no test. | `python/index.ts`, `go/index.ts` | +| 6 | **Two pagination resolvers implementing the same three-source precedence.** `paginationRuleFor` (declaration-only) and `resolveOperationPagination` (verifies fit, reports errors). Python goes through one, TypeScript the other — **they can disagree about whether an operation paginates.** | `authoring/pagination.ts`, `emitters/pagination.ts` | +| 7 | **Four hand-rolled doc-comment writers**, each re-deriving real per-language subtleties. | `writeDocstring` (py), `writeDocComment` (go), `writeDocComment` (php), `renderTitleComment` (ts) | +| 8 | **TypeScript syntax inside a "neutral" const.** `HEADER` is a hardcoded `//` comment, which is why `pythonGenerator` hand-writes its own `#` header. | `emit-options.ts:13` | +| 9 | **Indent units passed at call sites.** `new Printer(' ')`, `new Printer('\t')` — invisible in review. | `python/index.ts:234`, `go/index.ts:172` | +| 10 | **`anchor` is a four-line `path.parse` wrapper** used by all seven TypeScript generators; python re-implements it as `pythonModulePath`. | `generators/anchor.ts`, `python/index.ts:806` | +| 11 | **ADR-0001 and ARCHITECTURE.md describe deleted code.** Both document `ts.factory` AST codegen via `emitters/ts.ts` and `emitters/package-client.ts`. Neither module exists; every generator emits text. `jsdoc.ts` still refers the reader to `ts.ts`'s helper. | `docs/adr/0001`, `ARCHITECTURE.md`, `jsdoc.ts:12` | +| 12 | **`flatInputShape` contains no TypeScript.** It takes `OperationModel` + `NamedSchemaModel[]`, counts names, returns a verdict. It is TypeScript-only because it lives in `render-client.ts`. Python, Go, and PHP each re-derive the same collision question via `uniqueIdentifiers(…, { taken: METHOD_ARG_SLOTS })`. | `render-client.ts:174`, `python/index.ts:509`, `go/index.ts:494`, `php/index.ts:550` | Findings 4, 5, and 6 are correctness or security issues, not tidiness. @@ -130,21 +130,21 @@ The rule: **facts belong on the data, syntax belongs on the printer, shape belon Language-agnostic analysis over the IR, plus the authoring contract. -| Keep | Add (re-homed from `emitters/`) | -| --- | --- | -| `Printer` (structure only), `casing`, `identifierFor`, `uniqueIdentifiers`, `RESERVED_WORDS` | `inputNameCollisions` — the neutral half of `flatInputShape` | -| `flattenAllOf`, `discriminatorCases`, `isNullable`, `unwrapNullable`, `enumValues`, `schemaAtPointer`, `headerCoerceType` | — | -| `docText`, `renderReferencePage`, `NotSupportedError` | — | -| `Generator`, `GeneratorInput`, `CodeSample`, `SampleContext`, `DateType` | — | +| Keep | Add (re-homed from `emitters/`) | +| ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `Printer` (structure only), `casing`, `identifierFor`, `uniqueIdentifiers`, `RESERVED_WORDS` | `inputNameCollisions` — the neutral half of `flatInputShape` | +| `flattenAllOf`, `discriminatorCases`, `isNullable`, `unwrapNullable`, `enumValues`, `schemaAtPointer`, `headerCoerceType` | — | +| `docText`, `renderReferencePage`, `NotSupportedError` | — | +| `Generator`, `GeneratorInput`, `CodeSample`, `SampleContext`, `DateType` | — | **Removed by becoming data rather than helpers:** -| Helper | Becomes | -| --- | --- | -| `isSseOp`, `eventSchema`, `sseDataKind` | `op.sse?: { eventSchema?, dataKind }` — computed once by the IR builder | -| `paginationRuleFor` + `resolveModelPagination` + `resolveOperationPagination` | **one** resolver, run once by the pipeline → `input.pagination` | -| `anchor` | `input.output: { path, dir, stem, ext }` | -| `HEADER`, `banner`, `renderTitleComment` | `input.banner: string[]` (content) + `printer.doc()` (syntax) | +| Helper | Becomes | +| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `isSseOp`, `eventSchema`, `sseDataKind` | `op.sse?: { eventSchema?, dataKind }` — computed once by the IR builder | +| `paginationRuleFor` + `resolveModelPagination` + `resolveOperationPagination` | **one** resolver, run once by the pipeline → `input.pagination` | +| `anchor` | `input.output: { path, dir, stem, ext }` | +| `HEADER`, `banner`, `renderTitleComment` | `input.banner: string[]` (content) + `printer.doc()` (syntax) | ### 2. Language printers — `@redocly/client-generator/printers/` @@ -156,12 +156,12 @@ emitted code remains visible to whoever edits the generator next. Common core: `typeName`, `memberName`, `identifier`, `identifiers`, `string`, `literal`, `comment`, `doc`, plus a `layout(source)` pass and a baked-in `indentUnit`. -| Printer | Absorbs | Language-specific extension | -| --- | --- | --- | -| `TypeScriptPrinter` | `pascalCase`, `safeIdent`, `uniqueIdent`, `sanitizeIdentifier`, `codeString` + `sanitizeCodeString` (merged on the stricter policy), `codeLiteral`, `escapeJsDoc`, `jsdocText`, `splitLines` | `key(name)` — bare-or-quoted object key. No other language has quotable keys. | -| `PythonPrinter` | `className`, `fieldName`, `pythonLiteral`, `writeDocstring`, `Printer(' ')` | `constName` (SCREAMING_SNAKE); `memberName` reports whether it renamed, for `_field_map` | -| `GoPrinter` | `exported` (incl. the `_`→`N` rule), `writeDocComment`, `Printer('\t')` | `layout` = `gofmtShape` + `alignGoColumns`; `packageName` validation | -| `PhpPrinter` | `className`, `propertyName`, `phpString`, `writeDocComment` | `doc` takes `tags` — PHP's `array`/`\Generator` erase element types | +| Printer | Absorbs | Language-specific extension | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `TypeScriptPrinter` | `pascalCase`, `safeIdent`, `uniqueIdent`, `sanitizeIdentifier`, `codeString` + `sanitizeCodeString` (merged on the stricter policy), `codeLiteral`, `escapeJsDoc`, `jsdocText`, `splitLines` | `key(name)` — bare-or-quoted object key. No other language has quotable keys. | +| `PythonPrinter` | `className`, `fieldName`, `pythonLiteral`, `writeDocstring`, `Printer(' ')` | `constName` (SCREAMING_SNAKE); `memberName` reports whether it renamed, for `_field_map` | +| `GoPrinter` | `exported` (incl. the `_`→`N` rule), `writeDocComment`, `Printer('\t')` | `layout` = `gofmtShape` + `alignGoColumns`; `packageName` validation | +| `PhpPrinter` | `className`, `propertyName`, `phpString`, `writeDocComment` | `doc` takes `tags` — PHP's `array`/`\Generator` erase element types | Two notes on the extensions. Go's `exported` carries knowledge that must not be re-derived: `identifierFor` prefixes `_` for a @@ -172,7 +172,7 @@ adjacent lines, which is not known when the first line is emitted. ### 3. Generator-owned — `src/generators//` -Everything that decides output *shape*. +Everything that decides output _shape_. This runs in both directions. The TypeScript-family generators **gain** the modules that are theirs alone, as `emitters/` dissolves. @@ -180,27 +180,27 @@ The single-file generators are **split** into the same stages rather than left w (953 lines), `go` (1169), and `php` (1092) are self-contained already, but the uniform skeleton is what makes them comparable, and their existing functions re-group into it without rewriting: -| Stage | python | go | php | -| --- | --- | --- | --- | -| `naming` | `className`, `fieldName`, `operationIdents` | `exported`, `goOperationIdents` | `className`, `propertyName`, `methodName` | -| `types` | `pythonType` | `goType` | `phpType`, `phpNullable`, `phpUnionType` | -| `models` | `writeDataclass`, `renderPythonModels`, `pydanticDiscriminators` | `writeStruct`, `renderGoModels` | `writeClass`, `renderPhpModels`, `hydration`, `serialization` | -| `descriptor` | `securitySpecs`, `paginationSpec`, `envelopeHeaderSpecs` | `goSecurityLiteral`, `goPaginationLiteral` | `phpSecurityLiteral`, `phpPaginationLiteral` | -| `operations` | `writeMethod` | `writeGoMethod` | `writePhpMethod`, `methodArgs`, `writeRequestSetup` | -| `pagination` | `writePaginationWrappers` | `writeGoPaginationWrappers` | `writePhpPaginationWrappers` | -| `client` | `writePythonServers`, `writeClientClass` | `writeGoServers` | `writeServers` | +| Stage | python | go | php | +| ------------ | ---------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------- | +| `naming` | `className`, `fieldName`, `operationIdents` | `exported`, `goOperationIdents` | `className`, `propertyName`, `methodName` | +| `types` | `pythonType` | `goType` | `phpType`, `phpNullable`, `phpUnionType` | +| `models` | `writeDataclass`, `renderPythonModels`, `pydanticDiscriminators` | `writeStruct`, `renderGoModels` | `writeClass`, `renderPhpModels`, `hydration`, `serialization` | +| `descriptor` | `securitySpecs`, `paginationSpec`, `envelopeHeaderSpecs` | `goSecurityLiteral`, `goPaginationLiteral` | `phpSecurityLiteral`, `phpPaginationLiteral` | +| `operations` | `writeMethod` | `writeGoMethod` | `writePhpMethod`, `methodArgs`, `writeRequestSetup` | +| `pagination` | `writePaginationWrappers` | `writeGoPaginationWrappers` | `writePhpPaginationWrappers` | +| `client` | `writePythonServers`, `writeClientClass` | `writeGoServers` | `writeServers` | `emitters/` dissolves entirely: -| Generator | Absorbs | -| --- | --- | -| `typescript` | `client-assembly`, `render-client`, `descriptor`, `type-guards`, `reserved-names`, `response-headers`, `operation-types`, `operations`, `inline-runtime`, `runtime-sources`, `ts-type`, `emit-options` | -| `zod` | `zod.ts` | -| `mock` | `mock.ts`, `mock-value.ts`, `faker.ts`, `sample.ts` | -| `cli` | `cli.ts`, `cli-docs.ts` | -| `swr` | `swr.ts` | -| `tanstack-query` | `tanstack-query.ts` | -| `transformers` | `transformers.ts` | +| Generator | Absorbs | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `typescript` | `client-assembly`, `render-client`, `descriptor`, `type-guards`, `reserved-names`, `response-headers`, `operation-types`, `operations`, `inline-runtime`, `runtime-sources`, `ts-type`, `emit-options` | +| `zod` | `zod.ts` | +| `mock` | `mock.ts`, `mock-value.ts`, `faker.ts`, `sample.ts` | +| `cli` | `cli.ts`, `cli-docs.ts` | +| `swr` | `swr.ts` | +| `tanstack-query` | `tanstack-query.ts` | +| `transformers` | `transformers.ts` | ### 4. Generator contracts — `@redocly/client-generator/contracts/` diff --git a/packages/client-generator/src/authoring/__tests__/schema.test.ts b/packages/client-generator/src/authoring/__tests__/schema.test.ts index d1b156e7b4..0e42ad96be 100644 --- a/packages/client-generator/src/authoring/__tests__/schema.test.ts +++ b/packages/client-generator/src/authoring/__tests__/schema.test.ts @@ -102,6 +102,20 @@ describe('nullability and enums', () => { }); expect(enumValues(STRING)).toBeUndefined(); }); + + it('keeps every member name usable: negatives, decimals, folds, and the empty string', () => { + // `VALUE_-1 = -1` is a SyntaxError in Python — the names must survive any value. + const votes: SchemaModel = { kind: 'enum', values: [-1, 1, 1.5, 15], scalar: 'number' }; + expect(enumValues(votes)?.memberNames).toEqual([ + 'VALUE_MINUS_1', + 'VALUE_1', + 'VALUE_1_5', + 'VALUE_15', + ]); + // Two values folding to one name stay distinct, and an empty value still gets a member. + const folds: SchemaModel = { kind: 'enum', values: ['a-b', 'a b', ''], scalar: 'string' }; + expect(enumValues(folds)?.memberNames).toEqual(['A_B', 'A_B_2', '_']); + }); }); describe('docText', () => { diff --git a/packages/client-generator/src/authoring/schema.ts b/packages/client-generator/src/authoring/schema.ts index 4dbe4b436c..e508a3c9ee 100644 --- a/packages/client-generator/src/authoring/schema.ts +++ b/packages/client-generator/src/authoring/schema.ts @@ -8,7 +8,7 @@ import type { PropertyModel, SchemaModel, } from '../intermediate-representation/model.js'; -import { casing } from './naming.js'; +import { casing, uniqueIdentifiers } from './naming.js'; /** Follow a `ref` chain through the model's named schemas; undefined on a miss or cycle. */ function deref(schema: SchemaModel, model: ApiModel): SchemaModel | undefined { @@ -84,8 +84,16 @@ export function enumValues( schema: SchemaModel ): { values: Array; scalar: string; memberNames: string[] } | undefined { if (schema.kind !== 'enum') return undefined; - const memberNames = schema.values.map((value) => - typeof value === 'string' ? casing.screaming(value) : `VALUE_${String(value).toUpperCase()}` + // `casing` owns the value-to-word rules (`-1` → `MINUS_1`), and `uniqueIdentifiers` owns + // the rest of "language-safe": two values may fold to one name (`a-b` and `a b`), and an + // empty string folds to nothing at all — both must still yield distinct usable members. + const memberNames = uniqueIdentifiers( + schema.values.map((value) => + typeof value === 'string' + ? casing.screaming(value) + : `VALUE_${casing.screaming(String(value))}` + ), + { style: 'screaming' } ); return { values: schema.values, scalar: schema.scalar, memberNames }; } diff --git a/packages/client-generator/src/generators/__tests__/go.test.ts b/packages/client-generator/src/generators/__tests__/go.test.ts index 2dc7cc59d1..c11a0cb57b 100644 --- a/packages/client-generator/src/generators/__tests__/go.test.ts +++ b/packages/client-generator/src/generators/__tests__/go.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; -import { goGenerator, renderGoModels } from '../go/index.js'; +import { goGenerator, goSample, renderGoModels } from '../go/index.js'; const hasGo = spawnSync('go', ['version']).status === 0; @@ -219,6 +219,12 @@ const CAFE: ApiModel = { queryParams: [ { name: 'after', in: 'query', required: false, schema: STRING }, { name: 'limit', in: 'query', required: false, schema: INT }, + { + name: 'tags', + in: 'query', + required: false, + schema: { kind: 'array', items: STRING }, + }, ], headerParams: [], cookieParams: [], @@ -391,6 +397,23 @@ describe('goGenerator (full client assembly)', () => { }); }); +describe('query and sample shapes', () => { + it('an array query param repeats the key per element — fmt.Sprint would send "[a b]"', () => { + const out = generateGo(); + expect(out).toContain('for _, item := range *params.Tags {'); + expect(out).toContain('query.Add("tags", item)'); + expect(out).not.toContain('query.Set("tags"'); + }); + + it('the sample assignment matches the return shape: void has no result, SSE is one value', () => { + const ctx = { model: CAFE, outputPath: '/out/client.ts', emit: {} }; + const listOrders = CAFE.services[0].operations.find((op) => op.name === 'listOrders')!; + expect(goSample(listOrders, ctx)?.source).toContain('result, err := client.ListOrders('); + const streamEvents = CAFE.services[0].operations.find((op) => op.name === 'streamEvents')!; + expect(goSample(streamEvents, ctx)?.source).toContain('stream := client.StreamEvents('); + }); +}); + describe('goGenerator parity features', () => { it('paginated operations gain Pages/Items yield-func iterators with typed elements', () => { const out = generateGo(); diff --git a/packages/client-generator/src/generators/__tests__/php.test.ts b/packages/client-generator/src/generators/__tests__/php.test.ts index 30963fd5af..ebc202f9e4 100644 --- a/packages/client-generator/src/generators/__tests__/php.test.ts +++ b/packages/client-generator/src/generators/__tests__/php.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; -import { phpGenerator, phpType, renderPhpModels } from '../php/index.js'; +import { phpGenerator, phpSample, phpType, renderPhpModels } from '../php/index.js'; const hasPhp = spawnSync('php', ['--version']).status === 0; @@ -481,6 +481,85 @@ function generatePhp(): string { return files[0].content; } +describe('method names are unique across the client', () => { + const colliding: ApiModel = { + title: 'Collide', + version: '1.0.0', + serverUrl: 'https://api.example.com', + schemas: [], + securitySchemes: [], + services: [ + { + name: 'Default', + operations: [ + { + name: 'get_user', + specName: 'get-user', + method: 'get', + path: '/users/{id}', + tags: [], + pathParams: [ + { + name: 'id', + in: 'path', + required: true, + schema: { kind: 'scalar', scalar: 'string' }, + }, + ], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [], + errorResponses: [], + }, + { + name: 'getUser', + specName: 'getUser', + method: 'get', + path: '/users/by-name/{name}', + tags: [], + pathParams: [ + { + name: 'name', + in: 'path', + required: true, + schema: { kind: 'scalar', scalar: 'string' }, + }, + ], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [], + errorResponses: [], + }, + ], + }, + ], + } as unknown as ApiModel; + + it('two operations that camel-case alike get distinct methods — PHP fatals on a redeclare', () => { + const out = phpGenerator({ + model: colliding, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + })[0].content; + expect(out).toContain('public function getUser(string $id'); + expect(out).toContain('public function getUser2(string $name'); + }); + + it('the code sample names the deduped method, not the raw one', () => { + const sample = phpSample(colliding.services[0].operations[1], { + model: colliding, + outputPath: '/out/client.ts', + emit: {}, + }); + expect(sample?.source).toContain('$client->getUser2('); + }); +}); + describe('phpGenerator (full client assembly)', () => { it('assembles one runnable file: namespace, models, embedded runtime, operations, Client', () => { const out = generatePhp(); @@ -603,6 +682,55 @@ describe('phpGenerator (full client assembly)', () => { expectModelsRun(out); }); + it('a bare date-time success body hydrates to the DateTimeImmutable its signature declares', () => { + // The top-level hydration call dropped `dateType`, so the method returned the raw + // string while its own return type said `\\DateTimeImmutable`. + const dated: ApiModel = { + title: 'Cafe', + version: '1.0.0', + serverUrl: 'https://api.cafe.example', + schemas: [], + securitySchemes: [], + services: [ + { + name: 'Default', + operations: [ + { + name: 'getDeadline', + specName: 'getDeadline', + method: 'get', + path: '/deadline', + tags: [], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'scalar', scalar: 'string', metadata: { format: 'date-time' } }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + } as unknown as ApiModel; + const out = phpGenerator({ + model: dated, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { dateType: 'Date' }, + })[0].content; + expect(out).toContain( + 'public function getDeadline(?array $headers = null): \\DateTimeImmutable' + ); + expect(out).toContain('new \\DateTimeImmutable(decodeJson($response))'); + }); + it('maps date/date-time to DateTimeImmutable under dateType: Date, hydrating both ways', () => { const DATE_TIME: SchemaModel = { kind: 'scalar', diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts index 8f74b9036b..fd8e6bfc55 100644 --- a/packages/client-generator/src/generators/__tests__/python.test.ts +++ b/packages/client-generator/src/generators/__tests__/python.test.ts @@ -594,6 +594,72 @@ describe('pythonGenerator parity features', () => { expectCompiles(out); }); + it('iterator signatures annotate a date query param like the method does', () => { + // The `_pages`/`_items` wrappers dropped `dateType`, so `since` was `str` on the + // iterator while the method beside it said `datetime`. + const paged: ApiModel = { + title: 'Cafe', + version: '1.0.0', + serverUrl: 'https://api.cafe.example', + schemas: [], + securitySchemes: [], + services: [ + { + name: 'Orders', + operations: [ + { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + tags: [], + pathParams: [], + queryParams: [ + { + name: 'after', + in: 'query', + required: false, + schema: { kind: 'scalar', scalar: 'string' }, + }, + { + name: 'since', + in: 'query', + required: false, + schema: { kind: 'scalar', scalar: 'string', metadata: { format: 'date-time' } }, + }, + ], + headerParams: [], + cookieParams: [], + security: [], + paginationExtension: { + style: 'cursor', + cursorParam: 'after', + nextCursor: '/next', + items: '/items', + }, + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'object', properties: [] }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + } as unknown as ApiModel; + const out = pythonGenerator({ + model: paged, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { dateType: 'Date' }, + })[0].content; + expect(out).toMatch(/def list_orders_pages\([^)]*since: Optional\[datetime\]/); + expect(out).not.toMatch(/def list_orders_pages\([^)]*since: Optional\[str\]/); + }); + it('maps date/date-time to datetime objects under dateType: Date, and round-trips them', () => { const dated: ApiModel = { title: 'Cafe', diff --git a/packages/client-generator/src/generators/__tests__/resolve.test.ts b/packages/client-generator/src/generators/__tests__/resolve.test.ts index 3312fd9429..66dc6f6ae5 100644 --- a/packages/client-generator/src/generators/__tests__/resolve.test.ts +++ b/packages/client-generator/src/generators/__tests__/resolve.test.ts @@ -33,6 +33,23 @@ describe('resolveGenerators', () => { expect(registry.get('route-map')?.options).toEqual(custom.options); }); + it('keeps the docs and notApplicable hooks — an ejected generator exports both', async () => { + // Dropping either makes an ejected generator quietly do less than the built-in it + // replaced: `--docs` writes no page, ignored options stop warning. + const docs = noopRun; + const custom: CustomGenerator = { + name: 'route-map', + run: noopRun, + docs, + notApplicable: { importExt: 'it emits no imports' }, + }; + const { registry } = await resolveGenerators(['route-map'], { customGenerators: [custom] }); + expect(registry.get('route-map')?.docs).toBe(docs); + expect(registry.get('route-map')?.notApplicable).toEqual({ + importExt: 'it emits no imports', + }); + }); + it('registers an inline custom generator and selects it by name', async () => { const custom: CustomGenerator = { name: 'route-map', run: noopRun }; const { selected, registry } = await resolveGenerators(['typescript', 'route-map'], { diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index c7653ec073..74df0d7f87 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -204,8 +204,16 @@ function renderGoModelBodies(model: ApiModel, dateType: DateType): string { printer.block( 'const (', () => { + // Two values may fold to one pascal name (`1.5` and `15`) — a duplicate const + // would not compile, so the names are made unique per enum. A digit-leading + // value needs no `_` prefix here: the member starts with the type name. + const used = new Set(); asEnum.values.forEach((value) => { - const member = exported(name) + casing.pascal(String(value)); + const base = casing.pascal(String(value)) || 'Value'; + let suffix = ''; + for (let n = 2; used.has(base + suffix); n++) suffix = String(n); + used.add(base + suffix); + const member = exported(name) + base + suffix; printer.line(`${member} ${exported(name)} = ${JSON.stringify(value)}`); }); }, @@ -595,9 +603,25 @@ function writeGoMethod( printer.block( `if params.${field} != nil {`, () => { - printer.line( - `query.Set(${JSON.stringify(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema, dateType))})` - ); + // An array repeats the key per element (OpenAPI `form` + `explode`, the + // default — and what the TS runtime sends). `fmt.Sprint` of a slice + // would put `[a b]` on the wire as one value. + if (param.schema.kind === 'array') { + const elementType = goType(param.schema.items, dateType); + printer.block( + `for _, item := range *params.${field} {`, + () => { + printer.line( + `query.Add(${JSON.stringify(param.name)}, ${goQueryFormat('item', elementType)})` + ); + }, + '}' + ); + } else { + printer.line( + `query.Set(${JSON.stringify(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema, dateType))})` + ); + } }, '}' ); @@ -1128,7 +1152,11 @@ export function goSample(op: OperationModel, ctx: SampleContext): CodeSample { const dateType = ctx.emit.dateType ?? 'string'; // `goPackage` renames the package clause, and the snippet qualifies with it. const pkg = ctx.emit.goPackage ?? 'client'; - const ident = exported(op.name); + // The DEDUPED name: on a collision the method is `GetUser2`, and a snippet naming the + // raw `GetUser` would show a call that goes to a different operation. + const ident = + goOperationIdents(ctx.model).find((entry) => entry.op.name === op.name)?.ident ?? + exported(op.name); const args = [ 'ctx', ...op.pathParams.map( @@ -1137,10 +1165,19 @@ export function goSample(op: OperationModel, ctx: SampleContext): CodeSample { ...(op.requestBody ? [`${goType(op.requestBody.schema, dateType)}{ /* … */ }`] : []), ...(op.queryParams.length > 0 ? ['nil'] : []), ]; + // The assignment matches the return shape: an SSE method returns one iterator, a void + // method returns `error` alone — `result, err :=` would not compile against either. + const call = `client.${ident}(${args.join(', ')})`; + const statement = + sseResponse(op) !== undefined + ? `stream := ${call}` + : successSchema(op) === undefined + ? `err := ${call}` + : `result, err := ${call}`; return { lang: 'go', label: 'Go SDK', - source: `client := ${pkg}.New(${pkg}.Config{})\nresult, err := client.${ident}(${args.join(', ')})\n`, + source: `client := ${pkg}.New(${pkg}.Config{})\n${statement}\n`, }; } diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index f6273fb1e5..bd71914cda 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -179,7 +179,9 @@ function hydration( schema: SchemaModel, expr: string, model: ApiModel, - dateType: DateType = 'string' + // Required on purpose: a defaulted `'string'` let a call site forget it, and the method + // then returned a raw string where its own signature declared `\DateTimeImmutable`. + dateType: DateType ): string | undefined { const bare = unwrapNullable(schema); if (dateType === 'Date' && bare.kind === 'scalar' && bare.scalar === 'string') { @@ -395,10 +397,14 @@ export function renderPhpModels(model: ApiModel, dateType: DateType = 'string'): printer.block( '{', () => { - asEnum.values.forEach((value) => { - const member = identifierFor(String(value), { style: 'pascal', reserved: PHP }); + // `1.5` and `15` fold to one pascal name; PHP rejects a duplicate case. + const members = uniqueIdentifiers( + asEnum.values.map((value) => String(value)), + { style: 'pascal', reserved: PHP } + ); + asEnum.values.forEach((value, index) => { const literal = typeof value === 'string' ? phpString(value) : String(value); - printer.line(`case ${member} = ${literal};`); + printer.line(`case ${members[index]} = ${literal};`); }); }, '}' @@ -477,6 +483,20 @@ function methodName(op: OperationModel): string { return identifierFor(op.name, { style: 'camel', reserved: PHP }); } +/** + * The method name for every operation, unique across the client — PHP fatals on a + * redeclared method, and two operationIds may camel-case to one name (`get-user`, + * `getUser`). Keyed by the IR name, which the sanitizer already made unique. + */ +function methodIdents(model: ApiModel): Map { + const operations = model.services.flatMap((service) => service.operations); + const names = uniqueIdentifiers( + operations.map((op) => op.name), + { style: 'camel', reserved: PHP } + ); + return new Map(operations.map((op, index) => [op.name, names[index]])); +} + const MUTATING = new Set(['post', 'put', 'patch']); /** Security literal for the operations table, denormalized from the model's schemes. */ @@ -629,6 +649,7 @@ function envelopeHeaderSpecs(op: OperationModel, model: ApiModel): string { function writePhpMethod( printer: Printer, op: OperationModel, + ident: string, model: ApiModel, dateType: DateType, envelope = false @@ -650,13 +671,13 @@ function writePhpMethod( : rawBody ? 'string' : 'void'; - const name = envelope ? `${methodName(op)}WithHeaders` : methodName(op); + const name = envelope ? `${ident}WithHeaders` : ident; const element = envelope ? undefined : phpElementType(success, model, dateType); writeDocComment( printer, name, envelope - ? `Like ${methodName(op)}(), returning an Envelope with the declared response headers.` + ? `Like ${ident}(), returning an Envelope with the declared response headers.` : (op.summary ?? `${op.method.toUpperCase()} ${op.path}`), element === undefined ? [] : [`@return ${element}[]`] ); @@ -721,7 +742,8 @@ function writePhpMethod( ? "$response['body']" : ((success === undefined ? undefined - : hydration(success, 'decodeJson($response)', model)) ?? 'decodeJson($response)'); + : hydration(success, 'decodeJson($response)', model, dateType)) ?? + 'decodeJson($response)'); if (envelope) { printer.line(`$data = ${decoded};`); printer.line( @@ -748,6 +770,7 @@ function writePhpMethod( function writePhpPaginationWrappers( printer: Printer, op: OperationModel, + ident: string, model: ApiModel, dateType: DateType, pageHydration: string | undefined, @@ -756,7 +779,7 @@ function writePhpPaginationWrappers( itemYield: string ): void { const args = methodArgs(op, model, false, dateType); - const name = methodName(op); + const name = ident; const writeCall = () => { printer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`); @@ -960,6 +983,7 @@ export const phpGenerator: Generator = ({ model, outputPath, emit }) => { printer.blank(); const operations = model.services.flatMap((service) => service.operations); + const idents = methodIdents(model); const paginationRules = new Map(); for (const op of operations) { const rule = paginationRuleFor(op, emit.pagination as Record | undefined); @@ -1012,9 +1036,10 @@ export const phpGenerator: Generator = ({ model, outputPath, emit }) => { printer.blank(); for (const op of operations) { - writePhpMethod(printer, op, model, dateType); + const ident = idents.get(op.name)!; + writePhpMethod(printer, op, ident, model, dateType); if (sseResponse(op) === undefined && (op.successResponseHeaders?.length ?? 0) > 0) { - writePhpMethod(printer, op, model, dateType, true); + writePhpMethod(printer, op, ident, model, dateType, true); } const rule = paginationRules.get(op.name); if (rule === undefined) continue; @@ -1033,6 +1058,7 @@ export const phpGenerator: Generator = ({ model, outputPath, emit }) => { writePhpPaginationWrappers( printer, op, + ident, model, dateType, pageHydration, @@ -1063,7 +1089,7 @@ export function phpSample(op: OperationModel, ctx: SampleContext): CodeSample { 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`, + source: `require '${file}';\n\nuse ${namespace}\\{Client, Config};\n\n$client = new Client(new Config());\n$result = $client->${methodIdents(ctx.model).get(op.name) ?? methodName(op)}(${args.join(', ')});\n`, }; } diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index 20cb9e99fc..e5deaa4f0b 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -647,7 +647,7 @@ function writePaginationWrappers( ); const kwargs = [ ...queryArgs.map(({ param, python }) => { - const annotation = pythonType(param.schema); + const annotation = pythonType(param.schema, dateType); const optional = annotation.startsWith('Optional[') ? annotation : `Optional[${annotation}]`; return `${python}: ${optional} = None`; }), @@ -907,7 +907,11 @@ export function pythonSample(op: OperationModel, ctx: SampleContext): CodeSample const module = pythonModulePath(ctx.outputPath) .replace(/^.*[\\/]/, '') .replace(/\.py$/, ''); - const ident = identifierFor(op.name, { style: 'snake', reserved: PY }); + // The DEDUPED name: on a collision the method is `get_user_2`, and a snippet naming + // the raw `get_user` would show a call that goes to a different operation. + const ident = + operationIdents(ctx.model).find((entry) => entry.op.name === op.name)?.ident ?? + identifierFor(op.name, { style: 'snake', reserved: PY }); const args = [ ...op.pathParams.map((param) => { const python = identifierFor(param.name, { style: 'snake', reserved: PY }); diff --git a/packages/client-generator/src/generators/resolve.ts b/packages/client-generator/src/generators/resolve.ts index 36161b139e..6f3bbe9501 100644 --- a/packages/client-generator/src/generators/resolve.ts +++ b/packages/client-generator/src/generators/resolve.ts @@ -142,6 +142,11 @@ function register(registry: Map, custom: CustomGene registry.set(custom.name, { run: custom.run, sample: custom.sample, + // `docs` and `notApplicable` are part of the contract the ejected files export — + // dropping either makes an ejected generator quietly do less than the built-in it + // replaced (`--docs` writes no page, ignored options stop warning). + docs: custom.docs, + notApplicable: custom.notApplicable, options: custom.options, requires: custom.requires, errorModes: custom.errorModes, From 4cf84384794d8135b31e7c160ed2ece5cc6da956 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 17:27:59 +0300 Subject: [PATCH 07/35] refactor(client-generator): promote deref and the operation-shape trio to the toolkit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Which response is the JSON success, whether an operation streams, and whether its body is multipart were answered by identical private functions in python, go, and php — and shipped three more times in the eject assets. They are one answer each now: `jsonSuccessSchema`, `sseResponse`, and `isMultipartBody` in a new `authoring/operation.ts`, so two generators cannot disagree about the same operation, and a user-authored generator gets them instead of re-deriving them. `deref` goes public with them: `authoring/schema.ts` kept it private, and php had re-implemented it line for line. --- .../client-generator/eject-assets/AGENTS.md | 3 ++ .../skills/client-generators/SKILL.md | 3 ++ .../client-generator/src/authoring/index.ts | 6 +++ .../src/authoring/operation.ts | 28 ++++++++++++ .../client-generator/src/authoring/schema.ts | 2 +- .../src/generators/go/index.ts | 27 +++-------- .../src/generators/php/index.ts | 45 ++++--------------- .../src/generators/python/index.ts | 27 ++++------- 8 files changed, 65 insertions(+), 76 deletions(-) create mode 100644 packages/client-generator/src/authoring/operation.ts diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md index fd8b937dba..44e02cd41b 100644 --- a/packages/client-generator/eject-assets/AGENTS.md +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -94,6 +94,9 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, | Helper | Use | | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | | `flattenAllOf(schema, model)` | The merged property view of allOf compositions — languages without intersection types render this. | +| `deref(schema, model)` | Follow a `ref` chain to the schema it names (cycle-guarded). | +| `jsonSuccessSchema(op)` / `sseResponse(op)` | The primary JSON success schema; the `text/event-stream` response when the operation streams. | +| `isMultipartBody(op)` | Whether the request body is multipart. | | `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. | diff --git a/packages/client-generator/eject-assets/skills/client-generators/SKILL.md b/packages/client-generator/eject-assets/skills/client-generators/SKILL.md index 3a0250145a..93b05fe695 100644 --- a/packages/client-generator/eject-assets/skills/client-generators/SKILL.md +++ b/packages/client-generator/eject-assets/skills/client-generators/SKILL.md @@ -99,6 +99,9 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, | Helper | Use | | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | | `flattenAllOf(schema, model)` | The merged property view of allOf compositions — languages without intersection types render this. | +| `deref(schema, model)` | Follow a `ref` chain to the schema it names (cycle-guarded). | +| `jsonSuccessSchema(op)` / `sseResponse(op)` | The primary JSON success schema; the `text/event-stream` response when the operation streams. | +| `isMultipartBody(op)` | Whether the request body is multipart. | | `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. | diff --git a/packages/client-generator/src/authoring/index.ts b/packages/client-generator/src/authoring/index.ts index 67b9e36a7e..c1fcdd8db6 100644 --- a/packages/client-generator/src/authoring/index.ts +++ b/packages/client-generator/src/authoring/index.ts @@ -17,7 +17,9 @@ export { type ReferenceLanguage, type ReferencePageOptions, } from './reference-page.js'; +export { isMultipartBody, jsonSuccessSchema, sseResponse } from './operation.js'; export { + deref, discriminatorCases, docText, enumValues, @@ -36,7 +38,11 @@ export const AUTHORING_HELPER_NAMES = [ 'uniqueIdentifiers', 'RESERVED_WORDS', 'flattenAllOf', + 'deref', 'discriminatorCases', + 'jsonSuccessSchema', + 'sseResponse', + 'isMultipartBody', 'isNullable', 'unwrapNullable', 'enumValues', diff --git a/packages/client-generator/src/authoring/operation.ts b/packages/client-generator/src/authoring/operation.ts new file mode 100644 index 0000000000..940069d666 --- /dev/null +++ b/packages/client-generator/src/authoring/operation.ts @@ -0,0 +1,28 @@ +// Language-neutral operation-shape helpers: the questions every generator asks of an +// operation before deciding what to emit — which response is the JSON success, whether it +// streams, whether the body is multipart. One answer each, so two generators cannot +// disagree about the same operation. + +import type { + OperationModel, + ResponseBodyModel, + SchemaModel, +} from '../intermediate-representation/model.js'; + +/** The schema of the operation's primary JSON success response, if it has one. */ +export function jsonSuccessSchema(op: OperationModel): SchemaModel | undefined { + return op.successResponses.find((response) => response.contentType.toLowerCase().includes('json')) + ?.schema; +} + +/** The `text/event-stream` success response — present exactly when the operation streams. */ +export function sseResponse(op: OperationModel): ResponseBodyModel | undefined { + return op.successResponses.find((response) => + response.contentType.toLowerCase().includes('text/event-stream') + ); +} + +/** Whether the request body is multipart (any `multipart/*` content type). */ +export function isMultipartBody(op: OperationModel): boolean { + return op.requestBody?.contentType.toLowerCase().includes('multipart') ?? false; +} diff --git a/packages/client-generator/src/authoring/schema.ts b/packages/client-generator/src/authoring/schema.ts index e508a3c9ee..2e1f88de97 100644 --- a/packages/client-generator/src/authoring/schema.ts +++ b/packages/client-generator/src/authoring/schema.ts @@ -11,7 +11,7 @@ import type { import { casing, uniqueIdentifiers } from './naming.js'; /** Follow a `ref` chain through the model's named schemas; undefined on a miss or cycle. */ -function deref(schema: SchemaModel, model: ApiModel): SchemaModel | undefined { +export function deref(schema: SchemaModel, model: ApiModel): SchemaModel | undefined { const seen = new Set(); let current = schema; while (current.kind === 'ref') { diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index 74df0d7f87..ba3a692484 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -23,6 +23,9 @@ import { unwrapNullable, type DateType, type NeutralPaginationRule, + isMultipartBody, + jsonSuccessSchema, + sseResponse, } from '../../authoring/index.js'; import { GO_RUNTIME_SOURCE } from '../../emitters/go-runtime-sources.js'; import type { @@ -292,11 +295,6 @@ function renderGoModelBodies(model: ApiModel, dateType: DateType): string { 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 @@ -458,17 +456,6 @@ function stripHeader(source: string): string { return out.join('\n').trim(); } -/** The op's SSE success response, when it streams text/event-stream. */ -function sseResponse(op: OperationModel) { - return op.successResponses.find((response) => - response.contentType.toLowerCase().includes('text/event-stream') - ); -} - -function isMultipart(op: OperationModel): boolean { - return op.requestBody?.contentType.toLowerCase().includes('multipart') ?? false; -} - /** The neutral rule as a `&PaginationSpec{…}` composite literal for the operations table. */ function goPaginationLiteral(rule: NeutralPaginationRule): string { const fields = [ @@ -541,7 +528,7 @@ function writeGoMethod( ): void { const pathArgs = pathArguments(op, dateType); const hasParams = op.queryParams.length > 0; - const success = successSchema(op); + const success = jsonSuccessSchema(op); const returnType = success === undefined ? undefined : goType(success, dateType); const headerPlan = envelope ? envelopeHeaderPlan(op, model!) : []; if (envelope) { @@ -673,7 +660,7 @@ function writeGoMethod( 'Headers: authHeaders', 'Query: query', ]; - if (op.requestBody && isMultipart(op)) { + if (op.requestBody && isMultipartBody(op)) { printer.line('contentType, reader, err := toMultipart(body)'); printer.block( 'if err != nil {', @@ -1118,7 +1105,7 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { } const rule = paginationRules.get(ident); if (rule === undefined) continue; - const success = successSchema(op); + const success = jsonSuccessSchema(op); const pageType = success === undefined ? 'any' : goType(success, dateType); // Resolve the items ARRAY, then take its raw element, so a `ref` element // keeps its name (a deref'd result would type as `any`). @@ -1171,7 +1158,7 @@ export function goSample(op: OperationModel, ctx: SampleContext): CodeSample { const statement = sseResponse(op) !== undefined ? `stream := ${call}` - : successSchema(op) === undefined + : jsonSuccessSchema(op) === undefined ? `err := ${call}` : `result, err := ${call}`; return { diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index bd71914cda..66d6ce1d17 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -22,6 +22,10 @@ import { unwrapNullable, type NeutralPaginationRule, type DateType, + isMultipartBody, + jsonSuccessSchema, + sseResponse, + deref, } from '../../authoring/index.js'; import { PHP_RUNTIME_SOURCE } from '../../emitters/php-runtime-sources.js'; import type { @@ -48,21 +52,6 @@ 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); @@ -463,22 +452,6 @@ export function renderPhpModels(model: ApiModel, dateType: DateType = 'string'): 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 }); } @@ -587,7 +560,7 @@ function methodArgs( ...pathArgs.map(({ php, type }) => `${type} ${'$'}${php}`), ...(includeBody && op.requestBody ? [ - `${isMultipart(op) ? 'array' : phpType(op.requestBody.schema, model, dateType)} ${'$'}body`, + `${isMultipartBody(op) ? 'array' : phpType(op.requestBody.schema, model, dateType)} ${'$'}body`, ] : []), ...queryArgs.map(({ php, type }) => { @@ -656,7 +629,7 @@ function writePhpMethod( ): void { const args = methodArgs(op, model, true, dateType); const sse = sseResponse(op); - const success = successSchema(op); + const success = jsonSuccessSchema(op); // Non-JSON success bodies (PDFs, images, octet streams) return the raw body string. const rawBody = sse === undefined && @@ -716,7 +689,7 @@ function writePhpMethod( `'headers' => $requestHeaders`, `'query' => $query`, ]; - if (op.requestBody && isMultipart(op)) { + if (op.requestBody && isMultipartBody(op)) { printer.line('[$contentType, $encoded] = toMultipart($body);'); request.push(`'body' => $encoded`, `'contentType' => $contentType`); } else if (op.requestBody) { @@ -827,7 +800,7 @@ function writePhpPaginationWrappers( ); }; - const pageType = phpType(successSchema(op) ?? { kind: 'unknown' }, model, dateType); + const pageType = phpType(jsonSuccessSchema(op) ?? { kind: 'unknown' }, model, dateType); const pageYield = pageType === 'mixed' ? 'mixed' : pageType; printer.line('/**'); printer.line(` * ${name} response pages, following the pagination rule automatically.`); @@ -1043,7 +1016,7 @@ export const phpGenerator: Generator = ({ model, outputPath, emit }) => { } const rule = paginationRules.get(op.name); if (rule === undefined) continue; - const success = successSchema(op); + const success = jsonSuccessSchema(op); const pageHydration = success === undefined ? undefined : hydration(success, '$page', model, dateType); // Resolve the items ARRAY, then take its raw element, so a `ref` element diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index e5deaa4f0b..926416fb04 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -19,6 +19,9 @@ import { uniqueIdentifiers, unwrapNullable, type DateType, + isMultipartBody, + jsonSuccessSchema, + sseResponse, } from '../../authoring/index.js'; import { PYTHON_RUNTIME_SOURCES } from '../../emitters/python-runtime-sources.js'; import type { @@ -392,11 +395,6 @@ function discriminatorRegistrations(model: ApiModel, annotated: Set): st 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 @@ -450,15 +448,6 @@ function operationIdents(model: ApiModel): Array<{ op: OperationModel; ident: st 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( @@ -530,7 +519,7 @@ function writeMethod( 'retry: Optional[Dict[str, Any]] = None', 'idempotency_key: Any = None', ]; - const success = successSchema(op); + const success = jsonSuccessSchema(op); const sse = sseResponse(op); const returns = envelope ? `Envelope[${success === undefined ? 'None' : pythonType(success, dateType)}]` @@ -578,9 +567,9 @@ function writeMethod( 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)'); + if (isMultipartBody(op)) printer.line('form_data, form_files = to_multipart(body)'); const bodyKw = op.requestBody - ? isMultipart(op) + ? isMultipartBody(op) ? ', data=form_data, files=form_files' : ', json_body=encode(body)' : ''; @@ -628,7 +617,7 @@ function writePaginationWrappers( itemType: string, dateType: DateType ): void { - const success = successSchema(op); + const success = jsonSuccessSchema(op); const pageType = success === undefined ? 'Any' : pythonType(success, dateType); // The iterators take the same arguments as the operation itself, computed the same way, // so a name the method moved aside (`id_2`) is the same name here — copying a call from @@ -775,7 +764,7 @@ function writeClientClass( } const spec = paginationSpecs.get(ident); if (spec !== undefined) { - const success = successSchema(op); + const success = jsonSuccessSchema(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 = From e6f4459993133d98a950e8e67cfe88eb5506a2d9 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 17:31:41 +0300 Subject: [PATCH 08/35] refactor(client-generator): parse server-URL templates once, in the toolkit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `serverUrlExpression` was copied character-for-character into python, go, and php — same regex loop, same undeclared-variable comment — differing only in how a literal is quoted and parts are joined. The parsing is `serverUrlParts(server)` in the toolkit now, returning literal/variable parts, and each language keeps the five lines that are genuinely its own syntax. --- .../client-generator/eject-assets/AGENTS.md | 1 + .../skills/client-generators/SKILL.md | 1 + .../src/authoring/__tests__/operation.test.ts | 32 +++++++++++++++++++ .../client-generator/src/authoring/index.ts | 9 +++++- .../src/authoring/operation.ts | 32 +++++++++++++++++++ .../src/generators/go/index.ts | 27 ++++------------ .../src/generators/php/index.ts | 25 +++------------ .../src/generators/python/index.ts | 23 +++---------- 8 files changed, 90 insertions(+), 60 deletions(-) create mode 100644 packages/client-generator/src/authoring/__tests__/operation.test.ts diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md index 44e02cd41b..0dc8f46172 100644 --- a/packages/client-generator/eject-assets/AGENTS.md +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -97,6 +97,7 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, | `deref(schema, model)` | Follow a `ref` chain to the schema it names (cycle-guarded). | | `jsonSuccessSchema(op)` / `sseResponse(op)` | The primary JSON success schema; the `text/event-stream` response when the operation streams. | | `isMultipartBody(op)` | Whether the request body is multipart. | +| `serverUrlParts(server)` | A server-URL template as literal/variable parts, ready for any concatenation syntax. | | `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. | diff --git a/packages/client-generator/eject-assets/skills/client-generators/SKILL.md b/packages/client-generator/eject-assets/skills/client-generators/SKILL.md index 93b05fe695..44f5aa31db 100644 --- a/packages/client-generator/eject-assets/skills/client-generators/SKILL.md +++ b/packages/client-generator/eject-assets/skills/client-generators/SKILL.md @@ -102,6 +102,7 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, | `deref(schema, model)` | Follow a `ref` chain to the schema it names (cycle-guarded). | | `jsonSuccessSchema(op)` / `sseResponse(op)` | The primary JSON success schema; the `text/event-stream` response when the operation streams. | | `isMultipartBody(op)` | Whether the request body is multipart. | +| `serverUrlParts(server)` | A server-URL template as literal/variable parts, ready for any concatenation syntax. | | `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. | diff --git a/packages/client-generator/src/authoring/__tests__/operation.test.ts b/packages/client-generator/src/authoring/__tests__/operation.test.ts new file mode 100644 index 0000000000..298b0cac5f --- /dev/null +++ b/packages/client-generator/src/authoring/__tests__/operation.test.ts @@ -0,0 +1,32 @@ +import type { ServerModel } from '../../intermediate-representation/model.js'; +import { serverUrlParts } from '../operation.js'; + +describe('serverUrlParts', () => { + it('splits a template into literals and declared variables, in order', () => { + const server = { + url: 'https://{region}.api.example.com/{basePath}', + variables: [ + { name: 'region', default: 'us' }, + { name: 'basePath', default: 'v1' }, + ], + } as ServerModel; + expect(serverUrlParts(server)).toEqual([ + { kind: 'literal', value: 'https://' }, + { kind: 'variable', name: 'region' }, + { kind: 'literal', value: '.api.example.com/' }, + { kind: 'variable', name: 'basePath' }, + ]); + }); + + it('keeps an undeclared placeholder as literal text, and never returns zero parts', () => { + const undeclared = { + url: 'https://{region}.example.com', + variables: [], + } as unknown as ServerModel; + expect(serverUrlParts(undeclared)).toEqual([ + { kind: 'literal', value: 'https://{region}.example.com' }, + ]); + const empty = { url: '', variables: [] } as unknown as ServerModel; + expect(serverUrlParts(empty)).toEqual([{ kind: 'literal', value: '' }]); + }); +}); diff --git a/packages/client-generator/src/authoring/index.ts b/packages/client-generator/src/authoring/index.ts index c1fcdd8db6..9b3dd796be 100644 --- a/packages/client-generator/src/authoring/index.ts +++ b/packages/client-generator/src/authoring/index.ts @@ -17,7 +17,13 @@ export { type ReferenceLanguage, type ReferencePageOptions, } from './reference-page.js'; -export { isMultipartBody, jsonSuccessSchema, sseResponse } from './operation.js'; +export { + isMultipartBody, + jsonSuccessSchema, + serverUrlParts, + sseResponse, + type ServerUrlPart, +} from './operation.js'; export { deref, discriminatorCases, @@ -43,6 +49,7 @@ export const AUTHORING_HELPER_NAMES = [ 'jsonSuccessSchema', 'sseResponse', 'isMultipartBody', + 'serverUrlParts', 'isNullable', 'unwrapNullable', 'enumValues', diff --git a/packages/client-generator/src/authoring/operation.ts b/packages/client-generator/src/authoring/operation.ts index 940069d666..b461c9f68a 100644 --- a/packages/client-generator/src/authoring/operation.ts +++ b/packages/client-generator/src/authoring/operation.ts @@ -7,6 +7,7 @@ import type { OperationModel, ResponseBodyModel, SchemaModel, + ServerModel, } from '../intermediate-representation/model.js'; /** The schema of the operation's primary JSON success response, if it has one. */ @@ -26,3 +27,34 @@ export function sseResponse(op: OperationModel): ResponseBodyModel | undefined { export function isMultipartBody(op: OperationModel): boolean { return op.requestBody?.contentType.toLowerCase().includes('multipart') ?? false; } + +/** One piece of a parsed server-URL template: literal text, or a declared variable's name. */ +export type ServerUrlPart = { kind: 'literal'; value: string } | { kind: 'variable'; name: string }; + +/** + * A server's URL template as parts a generator concatenates in its own syntax: + * `https://{region}.api.example.com/v1` → literal, variable `region`, literal. A variable + * the server does not declare has nothing to substitute, so its placeholder stays literal + * text and remains visible in the generated code. + */ +export function serverUrlParts(server: ServerModel): ServerUrlPart[] { + const declared = new Set(server.variables.map((variable) => variable.name)); + const parts: ServerUrlPart[] = []; + let literal = ''; + let rest = server.url; + const template = /\{([^{}]+)\}/; + for (let match = template.exec(rest); match !== null; match = template.exec(rest)) { + literal += rest.slice(0, match.index); + if (declared.has(match[1])) { + if (literal !== '') parts.push({ kind: 'literal', value: literal }); + literal = ''; + parts.push({ kind: 'variable', name: match[1] }); + } else { + literal += match[0]; + } + rest = rest.slice(match.index + match[0].length); + } + literal += rest; + if (literal !== '' || parts.length === 0) parts.push({ kind: 'literal', value: literal }); + return parts; +} diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index ba3a692484..99215e6c92 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -26,6 +26,7 @@ import { isMultipartBody, jsonSuccessSchema, sseResponse, + serverUrlParts, } from '../../authoring/index.js'; import { GO_RUNTIME_SOURCE } from '../../emitters/go-runtime-sources.js'; import type { @@ -912,27 +913,13 @@ function writeGoPaginationWrappers( printer.blank(); } -/** The server URL as a Go expression: literals concatenated with declared-variable params. */ +/** The server URL as a Go 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(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)); + const parts = serverUrlParts(server).map((part) => + part.kind === 'literal' + ? JSON.stringify(part.value) + : identifierFor(part.name, { style: 'camel', reserved: GO }) + ); return parts.join(' + '); } diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index 66d6ce1d17..45fe8a55f2 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -26,6 +26,7 @@ import { jsonSuccessSchema, sseResponse, deref, + serverUrlParts, } from '../../authoring/index.js'; import { PHP_RUNTIME_SOURCE } from '../../emitters/php-runtime-sources.js'; import type { @@ -854,27 +855,11 @@ function writePhpPaginationWrappers( printer.blank(); } -/** The server URL as a PHP expression: literals concatenated with declared-variable arguments. */ +/** The server URL as a PHP 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(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)); + const parts = serverUrlParts(server).map((part) => + part.kind === 'literal' ? phpString(part.value) : `${'$'}${propertyName(part.name)}` + ); return parts.join(' . '); } diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index 926416fb04..ffcec42831 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -22,6 +22,7 @@ import { isMultipartBody, jsonSuccessSchema, sseResponse, + serverUrlParts, } from '../../authoring/index.js'; import { PYTHON_RUNTIME_SOURCES } from '../../emitters/python-runtime-sources.js'; import type { @@ -320,25 +321,9 @@ export function renderPythonModels( /** 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)); + const parts = serverUrlParts(server).map((part) => + part.kind === 'literal' ? JSON.stringify(part.value) : fieldName(part.name).python + ); return parts.join(' + '); } From 07cb9232ce132bc65e686a97a030609a4eb50e52 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 17:35:21 +0300 Subject: [PATCH 09/35] refactor(client-generator): denormalize operation security once, in the toolkit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scheme-key → {scheme, kind, name, in} mapping existed four times — python, go, php, and the TypeScript descriptor — with the same OR-alternatives/AND-sets shape and the same drop-unknown-scheme rule. It is `securityRequirements(op, model)` in the toolkit now; each consumer keeps only its own literal syntax. --- .../client-generator/eject-assets/AGENTS.md | 1 + .../skills/client-generators/SKILL.md | 1 + .../client-generator/src/authoring/index.ts | 3 ++ .../src/authoring/operation.ts | 36 +++++++++++++++++++ .../src/emitters/descriptor.ts | 18 ++-------- .../src/generators/go/index.ts | 32 ++++------------- .../src/generators/php/index.ts | 32 +++++------------ .../src/generators/python/index.ts | 27 +++----------- 8 files changed, 62 insertions(+), 88 deletions(-) diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md index 0dc8f46172..89b0295563 100644 --- a/packages/client-generator/eject-assets/AGENTS.md +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -98,6 +98,7 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, | `jsonSuccessSchema(op)` / `sseResponse(op)` | The primary JSON success schema; the `text/event-stream` response when the operation streams. | | `isMultipartBody(op)` | Whether the request body is multipart. | | `serverUrlParts(server)` | A server-URL template as literal/variable parts, ready for any concatenation syntax. | +| `securityRequirements(op, model)` | The operation's security as OR-alternatives of AND-sets, denormalized against the declared schemes. | | `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. | diff --git a/packages/client-generator/eject-assets/skills/client-generators/SKILL.md b/packages/client-generator/eject-assets/skills/client-generators/SKILL.md index 44f5aa31db..27bc603be5 100644 --- a/packages/client-generator/eject-assets/skills/client-generators/SKILL.md +++ b/packages/client-generator/eject-assets/skills/client-generators/SKILL.md @@ -103,6 +103,7 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, | `jsonSuccessSchema(op)` / `sseResponse(op)` | The primary JSON success schema; the `text/event-stream` response when the operation streams. | | `isMultipartBody(op)` | Whether the request body is multipart. | | `serverUrlParts(server)` | A server-URL template as literal/variable parts, ready for any concatenation syntax. | +| `securityRequirements(op, model)` | The operation's security as OR-alternatives of AND-sets, denormalized against the declared schemes. | | `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. | diff --git a/packages/client-generator/src/authoring/index.ts b/packages/client-generator/src/authoring/index.ts index 9b3dd796be..bccdf90505 100644 --- a/packages/client-generator/src/authoring/index.ts +++ b/packages/client-generator/src/authoring/index.ts @@ -20,8 +20,10 @@ export { export { isMultipartBody, jsonSuccessSchema, + securityRequirements, serverUrlParts, sseResponse, + type SecurityRequirement, type ServerUrlPart, } from './operation.js'; export { @@ -50,6 +52,7 @@ export const AUTHORING_HELPER_NAMES = [ 'sseResponse', 'isMultipartBody', 'serverUrlParts', + 'securityRequirements', 'isNullable', 'unwrapNullable', 'enumValues', diff --git a/packages/client-generator/src/authoring/operation.ts b/packages/client-generator/src/authoring/operation.ts index b461c9f68a..f0c5453580 100644 --- a/packages/client-generator/src/authoring/operation.ts +++ b/packages/client-generator/src/authoring/operation.ts @@ -4,6 +4,7 @@ // disagree about the same operation. import type { + ApiModel, OperationModel, ResponseBodyModel, SchemaModel, @@ -58,3 +59,38 @@ export function serverUrlParts(server: ServerModel): ServerUrlPart[] { if (literal !== '' || parts.length === 0) parts.push({ kind: 'literal', value: literal }); return parts; } + +/** One resolved security requirement: the scheme's key, kind, and (for apiKey) placement. */ +export type SecurityRequirement = + | { scheme: string; kind: 'bearer' | 'basic' } + | { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' }; + +/** + * The operation's security as OR-alternatives of AND-sets, denormalized against the + * declared schemes — the shape every generated runtime's auth resolver consumes. A key that + * names no declared scheme is dropped, and an alternative that ends up empty with it. + * Generators print this in their own literal syntax; the mapping itself has one answer. + */ +export function securityRequirements( + op: OperationModel, + model: Pick +): SecurityRequirement[][] { + return op.security + .map((alternative) => + alternative.flatMap((key): SecurityRequirement[] => { + const scheme = model.securitySchemes.find((candidate) => candidate.key === key); + if (scheme === undefined) return []; + if (scheme.kind === 'bearer' || scheme.kind === 'basic') { + return [{ scheme: key, kind: scheme.kind }]; + } + if (scheme.kind === 'apiKeyHeader') { + return [{ scheme: key, kind: 'apiKey', name: scheme.headerName, in: 'header' }]; + } + if (scheme.kind === 'apiKeyQuery') { + return [{ scheme: key, kind: 'apiKey', name: scheme.paramName, in: 'query' }]; + } + return [{ scheme: key, kind: 'apiKey', name: scheme.cookieName, in: 'cookie' }]; + }) + ) + .filter((alternative) => alternative.length > 0); +} diff --git a/packages/client-generator/src/emitters/descriptor.ts b/packages/client-generator/src/emitters/descriptor.ts index 85208cb4f0..07f4cc446b 100644 --- a/packages/client-generator/src/emitters/descriptor.ts +++ b/packages/client-generator/src/emitters/descriptor.ts @@ -3,6 +3,7 @@ // descriptor map (`satisfies Record` — the semver skew // guard against the runtime contract in src/runtime/types.ts). Text templates. +import { securityRequirements } from '../authoring/operation.js'; import { allOperations, type ApiModel, @@ -10,7 +11,6 @@ import { type OperationModel, type SecuritySchemeModel, } from '../intermediate-representation/model.js'; -import type { SecuritySpec } from '../runtime/types.js'; import { uniqueIdent } from './identifier.js'; import { isTypedMultipart } from './operation-types.js'; import type { ArgsStyle } from './operations.js'; @@ -54,21 +54,7 @@ function descriptorValue( ...(p.allowReserved !== undefined ? { allowReserved: p.allowReserved } : {}), }) ); - const toSpecs = (key: string): SecuritySpec[] => { - const s = schemes.find((scheme) => scheme.key === key); - if (!s) return []; - if (s.kind === 'bearer' || s.kind === 'basic') return [{ scheme: key, kind: s.kind }]; - if (s.kind === 'apiKeyHeader') { - return [{ scheme: key, kind: 'apiKey', name: s.headerName, in: 'header' }]; - } - if (s.kind === 'apiKeyQuery') { - return [{ scheme: key, kind: 'apiKey', name: s.paramName, in: 'query' }]; - } - return [{ scheme: key, kind: 'apiKey', name: s.cookieName, in: 'cookie' }]; - }; - const security = op.security - .map((alternative) => alternative.flatMap(toSpecs)) - .filter((alternative) => alternative.length > 0); + const security = securityRequirements(op, { securitySchemes: schemes }); const sse = isSseOp(op); const responseKind = sse ? 'sse' : responseText(op.successResponses, dateType).kind; const responseHeaders = responseHeaderSpecs(op.successResponseHeaders, schemas); diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index 99215e6c92..9e1c501312 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -27,6 +27,7 @@ import { jsonSuccessSchema, sseResponse, serverUrlParts, + securityRequirements, } from '../../authoring/index.js'; import { GO_RUNTIME_SOURCE } from '../../emitters/go-runtime-sources.js'; import type { @@ -298,32 +299,13 @@ function renderGoModelBodies(model: ApiModel, dateType: DateType): string { /** 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)}}`, - ]; - }) + const alternatives = securityRequirements(op, model).map((alternative) => + alternative.map((spec) => + spec.kind === 'apiKey' + ? `{Scheme: ${JSON.stringify(spec.scheme)}, Kind: "apiKey", Name: ${JSON.stringify(spec.name)}, In: ${JSON.stringify(spec.in)}}` + : `{Scheme: ${JSON.stringify(spec.scheme)}, Kind: ${JSON.stringify(spec.kind)}}` ) - .filter((alternative) => alternative.length > 0); + ); if (alternatives.length === 0) return undefined; return `[][]SecuritySpec{${alternatives.map((specs) => `{${specs.join(', ')}}`).join(', ')}}`; } diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index 45fe8a55f2..d35f25dca8 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -27,6 +27,7 @@ import { sseResponse, deref, serverUrlParts, + securityRequirements, } from '../../authoring/index.js'; import { PHP_RUNTIME_SOURCE } from '../../emitters/php-runtime-sources.js'; import type { @@ -475,32 +476,15 @@ 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)}]`, - ]; - }); + const alternatives = securityRequirements(op, model).map((alternative) => { + const specs = alternative.map((spec) => + spec.kind === 'apiKey' + ? `['kind' => 'apiKey', 'scheme' => ${phpString(spec.scheme)}, 'name' => ${phpString(spec.name)}, 'in' => ${phpString(spec.in)}]` + : `['kind' => ${phpString(spec.kind)}, 'scheme' => ${phpString(spec.scheme)}]` + ); return `[${specs.join(', ')}]`; }); + if (alternatives.length === 0) return undefined; return `[${alternatives.join(', ')}]`; } diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index ffcec42831..83be73f79a 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -23,6 +23,7 @@ import { jsonSuccessSchema, sseResponse, serverUrlParts, + securityRequirements, } from '../../authoring/index.js'; import { PYTHON_RUNTIME_SOURCES } from '../../emitters/python-runtime-sources.js'; import type { @@ -380,28 +381,6 @@ function discriminatorRegistrations(model: ApiModel, annotated: Set): st return lines; } -/** 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'; @@ -854,7 +833,9 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit, options }) id: op.specName ?? op.name, method: op.method.toUpperCase(), path: op.path, - ...(securitySpecs(op, model).length > 0 ? { security: securitySpecs(op, model) } : {}), + ...(securityRequirements(op, model).length > 0 + ? { security: securityRequirements(op, model) } + : {}), ...(paginationSpecs.get(ident) !== undefined ? { pagination: paginationSpecs.get(ident) } : {}), From b5422dbdcb9f6ed2708d53f246cbd0eecf71420a Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 17:39:40 +0300 Subject: [PATCH 10/35] refactor(client-generator): resolve a pagination rule's item element once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `schemaAtPointer` → is-it-an-array → take-the-raw-element block existed in python, go, and php with the same comment about why the element must stay a `ref`. It is `paginationItemSchema(pageSchema, itemsPointer, model)` in the toolkit now. The envelope-header plan stays per language on purpose: its key naming carries per-language knowledge (Go's digit-leading `N` rule, where a `_` prefix would make the field unexported and invisible to encoding/json) that the printers own in the next stage — promoting it here would have traded three small copies for one wrong abstraction. --- .../client-generator/eject-assets/AGENTS.md | 43 ++++++++++--------- .../skills/client-generators/SKILL.md | 43 ++++++++++--------- .../client-generator/src/authoring/index.ts | 2 + .../src/authoring/operation.ts | 17 ++++++++ .../src/generators/go/index.ts | 10 +---- .../src/generators/php/index.ts | 10 +---- .../src/generators/python/index.ts | 14 +++--- 7 files changed, 73 insertions(+), 66 deletions(-) diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md index 89b0295563..dc77c9384a 100644 --- a/packages/client-generator/eject-assets/AGENTS.md +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -91,27 +91,28 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, ## Helpers (import from '@redocly/client-generator') -| Helper | Use | -| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| `flattenAllOf(schema, model)` | The merged property view of allOf compositions — languages without intersection types render this. | -| `deref(schema, model)` | Follow a `ref` chain to the schema it names (cycle-guarded). | -| `jsonSuccessSchema(op)` / `sseResponse(op)` | The primary JSON success schema; the `text/event-stream` response when the operation streams. | -| `isMultipartBody(op)` | Whether the request body is multipart. | -| `serverUrlParts(server)` | A server-URL template as literal/variable parts, ready for any concatenation syntax. | -| `securityRequirements(op, model)` | The operation's security as OR-alternatives of AND-sets, denormalized against the declared schemes. | -| `discriminatorCases(schema, model)` | `{ property, cases }` dispatch table for discriminated unions. | -| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). | -| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | -| `headerCoerceType(schema, model)` | Response-header coerce hint (`integer`/`number`/`boolean`/`string`) through refs, nullables, and allOf wrappers. | -| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). | -| `uniqueIdentifiers(names, { style, reserved, taken })` | The same, made unique among themselves and among names you already took — for a signature that takes one argument per parameter. | -| `Printer` | Indentation-aware text builder — no manual whitespace bookkeeping. | -| `docText(description)` | Description as trimmed lines for any comment syntax. | -| `schemaAtPointer(schema, pointer, model)` | Resolve an RFC 6901 JSON pointer over a schema (through refs and allOf) — e.g. a pagination `items` pointer to its element type. | -| `paginationRuleFor(op, config)` | The pagination rule that applies to an operation (per-op config > extension > fitting convention), normalized. | -| `renderReferencePage(model, options)` | The Markdown reference page a generator's `docs` hook returns — your `sample` hook supplies its call snippets. | -| `NotSupportedError` | Throw it to reject an option the generator can't honor — the CLI prints the message as a user error, not a crash. | -| `AUTHORING_HELPER_NAMES` | The list of the above (introspection). | +| Helper | Use | +| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `flattenAllOf(schema, model)` | The merged property view of allOf compositions — languages without intersection types render this. | +| `deref(schema, model)` | Follow a `ref` chain to the schema it names (cycle-guarded). | +| `jsonSuccessSchema(op)` / `sseResponse(op)` | The primary JSON success schema; the `text/event-stream` response when the operation streams. | +| `isMultipartBody(op)` | Whether the request body is multipart. | +| `serverUrlParts(server)` | A server-URL template as literal/variable parts, ready for any concatenation syntax. | +| `securityRequirements(op, model)` | The operation's security as OR-alternatives of AND-sets, denormalized against the declared schemes. | +| `paginationItemSchema(pageSchema, itemsPointer, model)` | The raw element schema behind a pagination rule's `items` pointer — a `ref` element keeps its name. | +| `discriminatorCases(schema, model)` | `{ property, cases }` dispatch table for discriminated unions. | +| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). | +| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | +| `headerCoerceType(schema, model)` | Response-header coerce hint (`integer`/`number`/`boolean`/`string`) through refs, nullables, and allOf wrappers. | +| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). | +| `uniqueIdentifiers(names, { style, reserved, taken })` | The same, made unique among themselves and among names you already took — for a signature that takes one argument per parameter. | +| `Printer` | Indentation-aware text builder — no manual whitespace bookkeeping. | +| `docText(description)` | Description as trimmed lines for any comment syntax. | +| `schemaAtPointer(schema, pointer, model)` | Resolve an RFC 6901 JSON pointer over a schema (through refs and allOf) — e.g. a pagination `items` pointer to its element type. | +| `paginationRuleFor(op, config)` | The pagination rule that applies to an operation (per-op config > extension > fitting convention), normalized. | +| `renderReferencePage(model, options)` | The Markdown reference page a generator's `docs` hook returns — your `sample` hook supplies its call snippets. | +| `NotSupportedError` | Throw it to reject an option the generator can't honor — the CLI prints the message as a user error, not a crash. | +| `AUTHORING_HELPER_NAMES` | The list of the above (introspection). | Worked example: the built-in `python` generator (`packages/client-generator/src/generators/python/index.ts` in the Redocly CLI repo) is diff --git a/packages/client-generator/eject-assets/skills/client-generators/SKILL.md b/packages/client-generator/eject-assets/skills/client-generators/SKILL.md index 27bc603be5..1b39b8489c 100644 --- a/packages/client-generator/eject-assets/skills/client-generators/SKILL.md +++ b/packages/client-generator/eject-assets/skills/client-generators/SKILL.md @@ -96,27 +96,28 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, ## Helpers (import from '@redocly/client-generator') -| Helper | Use | -| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| `flattenAllOf(schema, model)` | The merged property view of allOf compositions — languages without intersection types render this. | -| `deref(schema, model)` | Follow a `ref` chain to the schema it names (cycle-guarded). | -| `jsonSuccessSchema(op)` / `sseResponse(op)` | The primary JSON success schema; the `text/event-stream` response when the operation streams. | -| `isMultipartBody(op)` | Whether the request body is multipart. | -| `serverUrlParts(server)` | A server-URL template as literal/variable parts, ready for any concatenation syntax. | -| `securityRequirements(op, model)` | The operation's security as OR-alternatives of AND-sets, denormalized against the declared schemes. | -| `discriminatorCases(schema, model)` | `{ property, cases }` dispatch table for discriminated unions. | -| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). | -| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | -| `headerCoerceType(schema, model)` | Response-header coerce hint (`integer`/`number`/`boolean`/`string`) through refs, nullables, and allOf wrappers. | -| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). | -| `uniqueIdentifiers(names, { style, reserved, taken })` | The same, made unique among themselves and among names you already took — for a signature that takes one argument per parameter. | -| `Printer` | Indentation-aware text builder — no manual whitespace bookkeeping. | -| `docText(description)` | Description as trimmed lines for any comment syntax. | -| `schemaAtPointer(schema, pointer, model)` | Resolve an RFC 6901 JSON pointer over a schema (through refs and allOf) — e.g. a pagination `items` pointer to its element type. | -| `paginationRuleFor(op, config)` | The pagination rule that applies to an operation (per-op config > extension > fitting convention), normalized. | -| `renderReferencePage(model, options)` | The Markdown reference page a generator's `docs` hook returns — your `sample` hook supplies its call snippets. | -| `NotSupportedError` | Throw it to reject an option the generator can't honor — the CLI prints the message as a user error, not a crash. | -| `AUTHORING_HELPER_NAMES` | The list of the above (introspection). | +| Helper | Use | +| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `flattenAllOf(schema, model)` | The merged property view of allOf compositions — languages without intersection types render this. | +| `deref(schema, model)` | Follow a `ref` chain to the schema it names (cycle-guarded). | +| `jsonSuccessSchema(op)` / `sseResponse(op)` | The primary JSON success schema; the `text/event-stream` response when the operation streams. | +| `isMultipartBody(op)` | Whether the request body is multipart. | +| `serverUrlParts(server)` | A server-URL template as literal/variable parts, ready for any concatenation syntax. | +| `securityRequirements(op, model)` | The operation's security as OR-alternatives of AND-sets, denormalized against the declared schemes. | +| `paginationItemSchema(pageSchema, itemsPointer, model)` | The raw element schema behind a pagination rule's `items` pointer — a `ref` element keeps its name. | +| `discriminatorCases(schema, model)` | `{ property, cases }` dispatch table for discriminated unions. | +| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). | +| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | +| `headerCoerceType(schema, model)` | Response-header coerce hint (`integer`/`number`/`boolean`/`string`) through refs, nullables, and allOf wrappers. | +| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). | +| `uniqueIdentifiers(names, { style, reserved, taken })` | The same, made unique among themselves and among names you already took — for a signature that takes one argument per parameter. | +| `Printer` | Indentation-aware text builder — no manual whitespace bookkeeping. | +| `docText(description)` | Description as trimmed lines for any comment syntax. | +| `schemaAtPointer(schema, pointer, model)` | Resolve an RFC 6901 JSON pointer over a schema (through refs and allOf) — e.g. a pagination `items` pointer to its element type. | +| `paginationRuleFor(op, config)` | The pagination rule that applies to an operation (per-op config > extension > fitting convention), normalized. | +| `renderReferencePage(model, options)` | The Markdown reference page a generator's `docs` hook returns — your `sample` hook supplies its call snippets. | +| `NotSupportedError` | Throw it to reject an option the generator can't honor — the CLI prints the message as a user error, not a crash. | +| `AUTHORING_HELPER_NAMES` | The list of the above (introspection). | Worked example: the built-in `python` generator (`packages/client-generator/src/generators/python/index.ts` in the Redocly CLI repo) is diff --git a/packages/client-generator/src/authoring/index.ts b/packages/client-generator/src/authoring/index.ts index bccdf90505..84624e96b3 100644 --- a/packages/client-generator/src/authoring/index.ts +++ b/packages/client-generator/src/authoring/index.ts @@ -20,6 +20,7 @@ export { export { isMultipartBody, jsonSuccessSchema, + paginationItemSchema, securityRequirements, serverUrlParts, sseResponse, @@ -53,6 +54,7 @@ export const AUTHORING_HELPER_NAMES = [ 'isMultipartBody', 'serverUrlParts', 'securityRequirements', + 'paginationItemSchema', 'isNullable', 'unwrapNullable', 'enumValues', diff --git a/packages/client-generator/src/authoring/operation.ts b/packages/client-generator/src/authoring/operation.ts index f0c5453580..f33572eabd 100644 --- a/packages/client-generator/src/authoring/operation.ts +++ b/packages/client-generator/src/authoring/operation.ts @@ -10,6 +10,7 @@ import type { SchemaModel, ServerModel, } from '../intermediate-representation/model.js'; +import { schemaAtPointer } from './schema.js'; /** The schema of the operation's primary JSON success response, if it has one. */ export function jsonSuccessSchema(op: OperationModel): SchemaModel | undefined { @@ -94,3 +95,19 @@ export function securityRequirements( ) .filter((alternative) => alternative.length > 0); } + +/** + * The element type of a paginated operation's items: resolve the rule's `items` pointer to + * the items ARRAY, then take its raw element — a `ref` element keeps its class name (a + * deref'd result would hydrate as plain data). Undefined when the pointer misses or the + * target is not an array. + */ +export function paginationItemSchema( + pageSchema: SchemaModel | undefined, + itemsPointer: string | undefined, + model: ApiModel +): SchemaModel | undefined { + if (pageSchema === undefined || itemsPointer === undefined) return undefined; + const itemsArray = schemaAtPointer(pageSchema, itemsPointer, model); + return itemsArray?.kind === 'array' ? itemsArray.items : undefined; +} diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index 9e1c501312..2ebf8ed43f 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -19,7 +19,6 @@ import { paginationRuleFor, renderReferencePage, RESERVED_WORDS, - schemaAtPointer, unwrapNullable, type DateType, type NeutralPaginationRule, @@ -28,6 +27,7 @@ import { sseResponse, serverUrlParts, securityRequirements, + paginationItemSchema, } from '../../authoring/index.js'; import { GO_RUNTIME_SOURCE } from '../../emitters/go-runtime-sources.js'; import type { @@ -1076,13 +1076,7 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { if (rule === undefined) continue; const success = jsonSuccessSchema(op); const pageType = success === undefined ? 'any' : goType(success, dateType); - // Resolve the items ARRAY, then take its raw element, so a `ref` element - // keeps its name (a deref'd result would type as `any`). - const itemsArray = - success !== undefined && rule.items !== undefined - ? schemaAtPointer(success, rule.items, model) - : undefined; - const element = itemsArray?.kind === 'array' ? itemsArray.items : undefined; + const element = paginationItemSchema(success, rule.items, model); writeGoPaginationWrappers( printer, op, diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index d35f25dca8..eb203171ce 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -18,7 +18,6 @@ import { paginationRuleFor, renderReferencePage, RESERVED_WORDS, - schemaAtPointer, unwrapNullable, type NeutralPaginationRule, type DateType, @@ -28,6 +27,7 @@ import { deref, serverUrlParts, securityRequirements, + paginationItemSchema, } from '../../authoring/index.js'; import { PHP_RUNTIME_SOURCE } from '../../emitters/php-runtime-sources.js'; import type { @@ -988,13 +988,7 @@ export const phpGenerator: Generator = ({ model, outputPath, emit }) => { const success = jsonSuccessSchema(op); const pageHydration = success === undefined ? undefined : hydration(success, '$page', model, dateType); - // Resolve the items ARRAY, then take its raw element, so a `ref` element - // keeps its class name (a deref'd result would hydrate as plain data). - const itemsArray = - success !== undefined && rule.items !== undefined - ? schemaAtPointer(success, rule.items, model) - : undefined; - const element = itemsArray?.kind === 'array' ? itemsArray.items : undefined; + const element = paginationItemSchema(success, rule.items, model); const itemHydration = element === undefined ? undefined : hydration(element, '$item', model, dateType); writePhpPaginationWrappers( diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index 83be73f79a..eab4d9b84c 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -7,7 +7,6 @@ import { Printer, paginationRuleFor, renderReferencePage, - schemaAtPointer, discriminatorCases, docText, enumValues, @@ -24,6 +23,7 @@ import { sseResponse, serverUrlParts, securityRequirements, + paginationItemSchema, } from '../../authoring/index.js'; import { PYTHON_RUNTIME_SOURCES } from '../../emitters/python-runtime-sources.js'; import type { @@ -729,13 +729,11 @@ function writeClientClass( const spec = paginationSpecs.get(ident); if (spec !== undefined) { const success = jsonSuccessSchema(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; + const element = paginationItemSchema( + success, + typeof spec.items === 'string' ? spec.items : undefined, + model + ); writePaginationWrappers( printer, op, From 09fd73b730dd4222b11e8743f4f01fd46cfac03a Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 17:42:14 +0300 Subject: [PATCH 11/35] refactor(client-generator): one TypeScript reserved-word list, one dedupe loop, no phantom parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 46-word TypeScript reserved list existed twice and had to be hand-synced — `emitters/identifier.ts` now reads `RESERVED_WORDS.typescript` (verified identical before merging). Python's `operationIdents` re-implemented `uniqueIdentifiers`, which the file already imported; it is a call to it now. Go keeps its own loop on purpose: its names go through `exported`, whose digit-leading `N` rule `identifierFor` does not know. And `paginationRuleFor` loses the `_model` parameter nothing ever passed. --- .../src/authoring/pagination.ts | 5 +- .../src/emitters/identifier.ts | 52 ++----------------- .../src/generators/python/index.ts | 19 +++---- .../.claude/skills/client-generators/SKILL.md | 38 ++++++++------ 4 files changed, 33 insertions(+), 81 deletions(-) diff --git a/packages/client-generator/src/authoring/pagination.ts b/packages/client-generator/src/authoring/pagination.ts index cec9c69218..de2ccf0686 100644 --- a/packages/client-generator/src/authoring/pagination.ts +++ b/packages/client-generator/src/authoring/pagination.ts @@ -4,7 +4,7 @@ // (schema-level advance-param/pointer checks) remains generation-side; this helper is // what every language generator shares. -import type { ApiModel, OperationModel } from '../intermediate-representation/model.js'; +import type { OperationModel } from '../intermediate-representation/model.js'; /** The normalized rule a generator renders into its runtime's pagination spec. */ export type NeutralPaginationRule = { @@ -25,8 +25,7 @@ export type NeutralPaginationRule = { */ export function paginationRuleFor( op: OperationModel, - config: Record | undefined, - _model?: ApiModel + config: Record | undefined ): NeutralPaginationRule | undefined { const configuration = config ?? {}; const id = op.specName ?? op.name; diff --git a/packages/client-generator/src/emitters/identifier.ts b/packages/client-generator/src/emitters/identifier.ts index 1130855593..9905360e40 100644 --- a/packages/client-generator/src/emitters/identifier.ts +++ b/packages/client-generator/src/emitters/identifier.ts @@ -1,3 +1,4 @@ +import { RESERVED_WORDS } from '../authoring/naming.js'; // Identifier sanitization — mapping OpenAPI names (which may contain `-`, `.`, // spaces, or be reserved words) onto valid TypeScript identifiers. Pure string // logic with no dependency on the IR or other emitters. @@ -5,55 +6,8 @@ /** Matches a string that is already a valid JS identifier (ignoring reserved words). */ const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/; -const TS_RESERVED = new Set([ - 'break', - 'case', - 'catch', - 'class', - 'const', - 'continue', - 'debugger', - 'default', - 'delete', - 'do', - 'else', - 'enum', - 'export', - 'extends', - 'false', - 'finally', - 'for', - 'function', - 'if', - 'import', - 'in', - 'instanceof', - 'new', - 'null', - 'return', - 'super', - 'switch', - 'this', - 'throw', - 'true', - 'try', - 'typeof', - 'var', - 'void', - 'while', - 'with', - 'yield', - // Strict-mode reserved words — generated files are ES modules, always strict. - 'await', - 'implements', - 'interface', - 'let', - 'package', - 'private', - 'protected', - 'public', - 'static', -]); +// One list for the package: `identifierFor` (suffix convention) reads the same set. +const TS_RESERVED = RESERVED_WORDS.typescript; /** True when `name` matches the JS identifier grammar (reserved words still pass). */ export function isIdentifier(name: string): boolean { diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index eab4d9b84c..f72d7b70f7 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -397,19 +397,12 @@ function pythonLiteral(value: unknown): string { /** 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; + const operations = model.services.flatMap((service) => service.operations); + const idents = uniqueIdentifiers( + operations.map((op) => op.name), + { style: 'snake', reserved: PY } + ); + return operations.map((op, index) => ({ op, ident: idents[index] })); } /** The neutral pagination rule mapped to the snake_case spec dict the embedded 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 index 3a0250145a..1b39b8489c 100644 --- 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 @@ -96,22 +96,28 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, ## Helpers (import from '@redocly/client-generator') -| Helper | Use | -| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| `flattenAllOf(schema, model)` | The merged property view of allOf compositions — languages without intersection types render this. | -| `discriminatorCases(schema, model)` | `{ property, cases }` dispatch table for discriminated unions. | -| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). | -| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | -| `headerCoerceType(schema, model)` | Response-header coerce hint (`integer`/`number`/`boolean`/`string`) through refs, nullables, and allOf wrappers. | -| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). | -| `uniqueIdentifiers(names, { style, reserved, taken })` | The same, made unique among themselves and among names you already took — for a signature that takes one argument per parameter. | -| `Printer` | Indentation-aware text builder — no manual whitespace bookkeeping. | -| `docText(description)` | Description as trimmed lines for any comment syntax. | -| `schemaAtPointer(schema, pointer, model)` | Resolve an RFC 6901 JSON pointer over a schema (through refs and allOf) — e.g. a pagination `items` pointer to its element type. | -| `paginationRuleFor(op, config)` | The pagination rule that applies to an operation (per-op config > extension > fitting convention), normalized. | -| `renderReferencePage(model, options)` | The Markdown reference page a generator's `docs` hook returns — your `sample` hook supplies its call snippets. | -| `NotSupportedError` | Throw it to reject an option the generator can't honor — the CLI prints the message as a user error, not a crash. | -| `AUTHORING_HELPER_NAMES` | The list of the above (introspection). | +| Helper | Use | +| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `flattenAllOf(schema, model)` | The merged property view of allOf compositions — languages without intersection types render this. | +| `deref(schema, model)` | Follow a `ref` chain to the schema it names (cycle-guarded). | +| `jsonSuccessSchema(op)` / `sseResponse(op)` | The primary JSON success schema; the `text/event-stream` response when the operation streams. | +| `isMultipartBody(op)` | Whether the request body is multipart. | +| `serverUrlParts(server)` | A server-URL template as literal/variable parts, ready for any concatenation syntax. | +| `securityRequirements(op, model)` | The operation's security as OR-alternatives of AND-sets, denormalized against the declared schemes. | +| `paginationItemSchema(pageSchema, itemsPointer, model)` | The raw element schema behind a pagination rule's `items` pointer — a `ref` element keeps its name. | +| `discriminatorCases(schema, model)` | `{ property, cases }` dispatch table for discriminated unions. | +| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). | +| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | +| `headerCoerceType(schema, model)` | Response-header coerce hint (`integer`/`number`/`boolean`/`string`) through refs, nullables, and allOf wrappers. | +| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). | +| `uniqueIdentifiers(names, { style, reserved, taken })` | The same, made unique among themselves and among names you already took — for a signature that takes one argument per parameter. | +| `Printer` | Indentation-aware text builder — no manual whitespace bookkeeping. | +| `docText(description)` | Description as trimmed lines for any comment syntax. | +| `schemaAtPointer(schema, pointer, model)` | Resolve an RFC 6901 JSON pointer over a schema (through refs and allOf) — e.g. a pagination `items` pointer to its element type. | +| `paginationRuleFor(op, config)` | The pagination rule that applies to an operation (per-op config > extension > fitting convention), normalized. | +| `renderReferencePage(model, options)` | The Markdown reference page a generator's `docs` hook returns — your `sample` hook supplies its call snippets. | +| `NotSupportedError` | Throw it to reject an option the generator can't honor — the CLI prints the message as a user error, not a crash. | +| `AUTHORING_HELPER_NAMES` | The list of the above (introspection). | Worked example: the built-in `python` generator (`packages/client-generator/src/generators/python/index.ts` in the Redocly CLI repo) is From bdacfa21ecb607c4b3d59db200d64727c11f7b4c Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 17:59:57 +0300 Subject: [PATCH 12/35] feat(client-generator): one syntax printer per output language (ADR-0021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The common `Printer` keeps structure; four new printers own each language's syntax — identifier safety, string escaping, literal rendering, comment and doc form, the indent unit, and (for Go) the layout pass `toString()` applies. They fill the same slots, which is the check that the abstraction is real: `typeName`, `memberName`, `identifier`, `identifiers`, `string`, `literal`, `comment`, `doc`. The per-language knowledge moves with them rather than being flattened: Go's digit-leading `N` rule (a `_` prefix means unexported, so encoding/json would silently skip the field) and its gofmt column alignment, Python's `memberName` reporting a rename for `_field_map`, PHP's `@tag` doc form, TypeScript's bare-or-quoted `key`. Python and Go gain a real `string()` policy — controls escaped, non-ASCII raw, a lone surrogate spelled (`\uXXXX`) in Python and replaced (U+FFFD) in Go, which cannot represent one — with call sites adopting it in the next change. python, go, and php now construct their printer and delegate naming, escaping, and doc comments to it; their generated output is byte-identical (verified on two fixtures each). The dogfooding guard gains the printer as a sharing tier — each generator may import its OWN language's printer, never another's — and the eject build rewrites the import to `@redocly/client-generator/printers/`, which is a new public subpath. --- packages/client-generator/package.json | 25 ++ .../scripts/generate-eject-assets.mjs | 1 + .../__tests__/language-dogfooding.test.ts | 34 +-- .../src/generators/go/index.ts | 144 ++---------- .../src/generators/php/index.ts | 55 ++--- .../src/generators/python/index.ts | 50 ++-- .../src/printers/__tests__/printers.test.ts | 112 +++++++++ packages/client-generator/src/printers/go.ts | 213 ++++++++++++++++++ .../client-generator/src/printers/index.ts | 9 + packages/client-generator/src/printers/php.ts | 70 ++++++ .../client-generator/src/printers/python.ts | 94 ++++++++ .../src/printers/typescript.ts | 68 ++++++ 12 files changed, 660 insertions(+), 215 deletions(-) create mode 100644 packages/client-generator/src/printers/__tests__/printers.test.ts create mode 100644 packages/client-generator/src/printers/go.ts create mode 100644 packages/client-generator/src/printers/index.ts create mode 100644 packages/client-generator/src/printers/php.ts create mode 100644 packages/client-generator/src/printers/python.ts create mode 100644 packages/client-generator/src/printers/typescript.ts diff --git a/packages/client-generator/package.json b/packages/client-generator/package.json index 8ab58e2f69..b5069e3c36 100644 --- a/packages/client-generator/package.json +++ b/packages/client-generator/package.json @@ -16,6 +16,31 @@ "import": "./lib/generate.js", "default": "./lib/generate.js" }, + "./printers/python": { + "types": "./lib/printers/python.d.ts", + "import": "./lib/printers/python.js", + "default": "./lib/printers/python.js" + }, + "./printers/go": { + "types": "./lib/printers/go.d.ts", + "import": "./lib/printers/go.js", + "default": "./lib/printers/go.js" + }, + "./printers/php": { + "types": "./lib/printers/php.d.ts", + "import": "./lib/printers/php.js", + "default": "./lib/printers/php.js" + }, + "./printers/typescript": { + "types": "./lib/printers/typescript.d.ts", + "import": "./lib/printers/typescript.js", + "default": "./lib/printers/typescript.js" + }, + "./printers": { + "types": "./lib/printers/index.d.ts", + "import": "./lib/printers/index.js", + "default": "./lib/printers/index.js" + }, "./runtime-sources": { "types": "./lib/runtime-sources.d.ts", "import": "./lib/runtime-sources.js", diff --git a/packages/client-generator/scripts/generate-eject-assets.mjs b/packages/client-generator/scripts/generate-eject-assets.mjs index 2c4ef04b30..89aa60fb34 100644 --- a/packages/client-generator/scripts/generate-eject-assets.mjs +++ b/packages/client-generator/scripts/generate-eject-assets.mjs @@ -219,6 +219,7 @@ for (const { name, imports, run, sample, options, docs } of TYPESCRIPT) { 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(`'../../printers/${name}.js'`, `'@redocly/client-generator/printers/${name}'`) .replaceAll( `'../../emitters/${name}-runtime-sources.js'`, "'@redocly/client-generator/runtime-sources'" diff --git a/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts b/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts index 0912c5d00b..be77334bd5 100644 --- a/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts +++ b/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts @@ -7,27 +7,27 @@ import { fileURLToPath } from 'node:url'; // 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([ +const SHARED_SPECIFIERS = [ '../../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([]); - }); - } -); +describe.each(['python', 'go', 'php'])('%s/index.ts dogfooding invariant', (language) => { + it('imports only what the authoring skill offers to any custom generator', () => { + const source = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '..', language, 'index.ts'), + 'utf-8' + ); + // A generator's sharing tiers (ADR-0020): the neutral toolkit, its OWN language + // printer — never another language's — the runtime sources, and the contract. + const allowed = new Set([...SHARED_SPECIFIERS, `../../printers/${language}.js`]); + const specifiers = [...source.matchAll(/from '([^']+)'/g)].map((match) => match[1]); + expect(specifiers.length).toBeGreaterThan(0); + const violations = specifiers.filter((specifier) => !allowed.has(specifier)); + expect(violations).toEqual([]); + }); +}); diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index 2ebf8ed43f..7b490a13c1 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -6,9 +6,7 @@ import { casing, - Printer, discriminatorCases, - docText, enumValues, flattenAllOf, headerCoerceType, @@ -38,6 +36,7 @@ import type { SchemaModel, ServerModel, } from '../../intermediate-representation/model.js'; +import { exported, GoPrinter } from '../../printers/go.js'; import type { CodeSample, Generator, SampleContext } from '../types.js'; const GO = RESERVED_WORDS.go; @@ -57,13 +56,6 @@ function goPackageName(configured: string | undefined): string { } /** 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)) { @@ -111,32 +103,14 @@ export function goType(schema: SchemaModel, dateType: DateType = 'string'): stri } } -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, + printer: GoPrinter, name: string, properties: PropertyModel[], dateType: DateType, description?: string ): void { - writeDocComment(printer, exported(name), description); + printer.doc(exported(name), description); printer.block( `type ${exported(name)} struct {`, () => { @@ -163,18 +137,9 @@ function writeStruct( 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'); + const printer = new GoPrinter(); printer.line('package client'); printer.blank(); const needsJSON = model.schemas.some( @@ -192,18 +157,18 @@ export function renderGoModels(model: ApiModel, dateType: DateType = 'string'): printer.blank(); } printer.line(body); - return gofmtShape(alignGoColumns(printer.toString())); + return 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'); + const printer = new GoPrinter(); for (const { name, schema } of model.schemas) { const asEnum = enumValues(schema); if (asEnum !== undefined) { const base = asEnum.scalar === 'string' ? 'string' : 'int64'; - writeDocComment(printer, exported(name), schema.description); + printer.doc(exported(name), schema.description); printer.line(`type ${exported(name)} ${base}`); printer.blank(); printer.block( @@ -290,7 +255,7 @@ function renderGoModelBodies(model: ApiModel, dateType: DateType): string { continue; } // Everything else (plain unions, scalar aliases, records) becomes a type alias. - writeDocComment(printer, exported(name), schema.description); + printer.doc(exported(name), schema.description); printer.line(`type ${exported(name)} = ${goType(schema, dateType)}`); printer.blank(); } @@ -340,84 +305,6 @@ function goQueryFormat(expr: string, type: string): string { 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'); @@ -502,7 +389,7 @@ function envelopeHeaderPlan( } function writeGoMethod( - printer: Printer, + printer: GoPrinter, op: OperationModel, ident: string, dateType: DateType, @@ -552,8 +439,7 @@ function writeGoMethod( ? `return ${errExpr}` : `return out, ${errExpr}`; const funcName = envelope ? `${ident}WithHeaders` : ident; - writeDocComment( - printer, + printer.doc( funcName, envelope ? `Like ${ident}, also returning the declared response headers.` : op.summary ); @@ -715,7 +601,7 @@ function writeGoMethod( /** `Pages` / `Items` iterators over the runtime's `iterPages`, hydrated via `reencode`. */ function writeGoPaginationWrappers( - printer: Printer, + printer: GoPrinter, op: OperationModel, ident: string, dateType: DateType, @@ -906,7 +792,7 @@ function serverUrlExpression(server: ServerModel): string { } /** One `URL` function per declared server; server variables become parameters. */ -function writeGoServers(printer: Printer, model: ApiModel): void { +function writeGoServers(printer: GoPrinter, model: ApiModel): void { const servers = model.servers ?? []; if (servers.length === 0) return; const usedNames = new Set(); @@ -939,7 +825,7 @@ function writeGoServers(printer: Printer, model: ApiModel): void { /** The whole generated file: models + embedded runtime + operations table + Client. */ export const goGenerator: Generator = ({ model, outputPath, emit }) => { - const printer = new Printer('\t'); + const printer = new GoPrinter(); const dateType = emit.dateType ?? 'string'; const packageName = goPackageName(emit.goPackage); const paginationRules = new Map(); @@ -1040,7 +926,7 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { printer.blank(); } - writeDocComment(printer, 'Client', `Client for ${model.title} (${model.version}).`); + printer.doc('Client', `Client for ${model.title} (${model.version}).`); printer.block( 'type Client struct {', () => { @@ -1092,7 +978,7 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { 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())), + content: printer.toString(), }, ]; }; diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index eb203171ce..3911934d7e 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -6,8 +6,6 @@ // embedded runtime. Exceptions are the error mode (`errorMode` does not apply). import { - Printer, - docText, discriminatorCases, enumValues, flattenAllOf, @@ -37,21 +35,25 @@ import type { SchemaModel, ServerModel, } from '../../intermediate-representation/model.js'; +import { PhpPrinter } from '../../printers/php.js'; import type { CodeSample, Generator, SampleContext } from '../types.js'; const PHP = RESERVED_WORDS.php; +// Naming and escaping delegate to the printer — one implementation, one policy. +const naming = new PhpPrinter(); + function className(name: string): string { - return identifierFor(name, { style: 'pascal', reserved: PHP }); + return naming.typeName(name); } function propertyName(name: string): string { - return identifierFor(name, { style: 'camel', reserved: PHP }); + return naming.memberName(name); } /** `'…'` with backslashes and quotes escaped — safe for any spec-supplied text. */ function phpString(value: string): string { - return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; + return naming.string(value); } /** What a named schema renders as: a class, a native enum, or nothing (alias). */ @@ -239,26 +241,6 @@ function serialization( 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 @@ -284,7 +266,7 @@ function phpElementType( } function writeClass( - printer: Printer, + printer: PhpPrinter, name: string, properties: PropertyModel[], model: ApiModel, @@ -296,7 +278,7 @@ function writeClass( ...properties.filter((property) => property.required), ...properties.filter((property) => !property.required), ]; - writeDocComment(printer, className(name), description); + printer.doc(className(name), description); printer.line(`final class ${className(name)}`); printer.block( '{', @@ -378,12 +360,12 @@ function writeClass( /** Render every named schema: classes (allOf flattened), native enums, union dispatchers. */ export function renderPhpModels(model: ApiModel, dateType: DateType = 'string'): string { - const printer = new Printer(' '); + const printer = new PhpPrinter(); for (const { name, schema } of model.schemas) { const asEnum = enumValues(schema); if (asEnum !== undefined && (asEnum.scalar === 'string' || asEnum.scalar === 'integer')) { const backing = asEnum.scalar === 'string' ? 'string' : 'int'; - writeDocComment(printer, className(name), schema.description); + printer.doc(className(name), schema.description); printer.line(`enum ${className(name)}: ${backing}`); printer.block( '{', @@ -561,7 +543,7 @@ function methodArgs( } /** The shared prologue: resolve auth, build query/url, merge headers. */ -function writeRequestSetup(printer: Printer, op: OperationModel, args: MethodArgs): void { +function writeRequestSetup(printer: PhpPrinter, op: OperationModel, args: MethodArgs): void { printer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`); printer.line( "[$authHeaders, $query, $cookies] = resolveAuth($op['security'] ?? [], $this->config->auth);" @@ -605,7 +587,7 @@ function envelopeHeaderSpecs(op: OperationModel, model: ApiModel): string { } function writePhpMethod( - printer: Printer, + printer: PhpPrinter, op: OperationModel, ident: string, model: ApiModel, @@ -631,8 +613,7 @@ function writePhpMethod( : 'void'; const name = envelope ? `${ident}WithHeaders` : ident; const element = envelope ? undefined : phpElementType(success, model, dateType); - writeDocComment( - printer, + printer.doc( name, envelope ? `Like ${ident}(), returning an Envelope with the declared response headers.` @@ -726,7 +707,7 @@ function writePhpMethod( /** `Pages()` / `Items()` generators over the runtime's iterPages. */ function writePhpPaginationWrappers( - printer: Printer, + printer: PhpPrinter, op: OperationModel, ident: string, model: ApiModel, @@ -848,7 +829,7 @@ function serverUrlExpression(server: ServerModel): string { } /** One static method per declared server; server variables become named string arguments. */ -function writeServers(printer: Printer, model: ApiModel): void { +function writeServers(printer: PhpPrinter, model: ApiModel): void { const servers = model.servers ?? []; if (servers.length === 0) return; const usedNames = new Set(); @@ -902,7 +883,7 @@ function stripPhpHeader(source: string): string { /** The whole generated file: namespace + models + embedded runtime + operations + Client. */ export const phpGenerator: Generator = ({ model, outputPath, emit }) => { - const printer = new Printer(' '); + const printer = new PhpPrinter(); const dateType = emit.dateType ?? 'string'; const namespace = identifierFor(model.title, { style: 'pascal', reserved: PHP }); printer.line(' { ); printer.blank(); - writeDocComment(printer, 'Client', `Client for ${model.title} (${model.version}).`); + printer.doc('Client', `Client for ${model.title} (${model.version}).`); // Not final: PHP test suites mock concrete classes (createMock(Client::class)). printer.line('class Client'); printer.block( diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index f72d7b70f7..edde70c457 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -4,11 +4,9 @@ // A guard test pins that this module never imports the TS emitter toolkit. import { - Printer, paginationRuleFor, renderReferencePage, discriminatorCases, - docText, enumValues, flattenAllOf, headerCoerceType, @@ -33,19 +31,23 @@ import type { SchemaModel, ServerModel, } from '../../intermediate-representation/model.js'; +import { PythonPrinter } from '../../printers/python.js'; import type { CodeSample, Generator, GeneratorOptionsSchema, SampleContext } from '../types.js'; const PY = RESERVED_WORDS.python; +// Naming delegates to the printer — one implementation, used here and by any ejected copy. +const naming = new PythonPrinter(); + /** A named schema's Python class name. */ function className(name: string): string { - return identifierFor(name, { style: 'pascal', reserved: PY }); + return naming.typeName(name); } /** 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 }; + const { identifier, renamed } = naming.memberName(name); + return { python: identifier, renamed }; } /** The Python type annotation for a schema (anonymous complex shapes collapse to Any-ish). */ @@ -88,18 +90,6 @@ export function pythonType(schema: SchemaModel, dateType: DateType = 'string'): } } -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'; @@ -175,7 +165,7 @@ function pydanticDiscriminators(model: ApiModel): { const METHOD_ARG_SLOTS = ['self', 'body', 'headers', 'timeout', 'retry', 'idempotency_key']; function writeDataclass( - printer: Printer, + printer: PythonPrinter, name: string, properties: PropertyModel[], dateType: DateType, @@ -188,7 +178,7 @@ function writeDataclass( if (!pydantic) printer.line('@dataclass'); const header = pydantic ? `class ${className(name)}(BaseModel):` : `class ${className(name)}:`; printer.block(header, () => { - writeDocstring(printer, description); + printer.doc(description); // A wire name that is not a legal field name travels as an alias, so the model // accepts both spellings; without this, populating by field name would fail. if (pydantic) { @@ -236,7 +226,7 @@ export function renderPythonModels( dateType: DateType = 'string', models: PythonModels = 'dataclass' ): string { - const printer = new Printer(' '); + const printer = new PythonPrinter(); const { pins, unions } = models === 'pydantic' ? pydanticDiscriminators(model) @@ -273,7 +263,7 @@ export function renderPythonModels( if (asEnum !== undefined) { const base = asEnum.scalar === 'string' ? 'str, Enum' : 'int, Enum'; printer.block(`class ${className(name)}(${base}):`, () => { - writeDocstring(printer, schema.description); + printer.doc(schema.description); asEnum.values.forEach((value, index) => { printer.line(`${asEnum.memberNames[index]} = ${JSON.stringify(value)}`); }); @@ -329,7 +319,7 @@ function serverUrlExpression(server: ServerModel): string { } /** One static method per declared server; server variables become keyword arguments. */ -function writePythonServers(printer: Printer, model: ApiModel): void { +function writePythonServers(printer: PythonPrinter, model: ApiModel): void { const servers = model.servers ?? []; if (servers.length === 0) return; const usedNames = new Set(); @@ -439,7 +429,7 @@ function envelopeHeaderSpecs(op: OperationModel, model: ApiModel): string { } function writeMethod( - printer: Printer, + printer: PythonPrinter, op: OperationModel, ident: string, errorMode: 'throw' | 'result', @@ -495,8 +485,7 @@ function writeMethod( const signature = ['self', ...positional, ...bodyArg, '*', ...kwargs].join(', '); const defName = envelope ? `${ident}_with_headers` : ident; printer.block(`${prefix} ${defName}(${signature}) -> ${returns}:`, () => { - writeDocstring( - printer, + printer.doc( envelope ? `Like ${ident}(), returning an Envelope with the declared response headers.` : op.summary @@ -567,7 +556,7 @@ function writeMethod( /** `_pages` / `_items` iterator methods for a paginated operation. */ function writePaginationWrappers( - printer: Printer, + printer: PythonPrinter, op: OperationModel, ident: string, isAsync: boolean, @@ -677,7 +666,7 @@ function writePaginationWrappers( } function writeClientClass( - printer: Printer, + printer: PythonPrinter, model: ApiModel, errorMode: 'throw' | 'result', isAsync: boolean, @@ -688,10 +677,7 @@ function writeClientClass( 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.doc(`${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, ' + @@ -761,7 +747,7 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit, options }) const dateType = emit.dateType ?? 'string'; const models = (options?.models as PythonModels | undefined) ?? 'dataclass'; const pydantic = models === 'pydantic' ? pydanticDiscriminators(model) : undefined; - const printer = new Printer(' '); + const printer = new PythonPrinter(); printer.line( `# Generated by @redocly/client-generator (python) from "${model.title}" ${model.version}.` ); diff --git a/packages/client-generator/src/printers/__tests__/printers.test.ts b/packages/client-generator/src/printers/__tests__/printers.test.ts new file mode 100644 index 0000000000..2060496c4a --- /dev/null +++ b/packages/client-generator/src/printers/__tests__/printers.test.ts @@ -0,0 +1,112 @@ +import { GoPrinter, exported } from '../go.js'; +import { PhpPrinter } from '../php.js'; +import { PythonPrinter } from '../python.js'; +import { TypeScriptPrinter } from '../typescript.js'; + +// The check that the abstraction is real: four printers fill the same slots, each with +// its language's answer — not a bag of leftovers (ADR-0021). + +describe('naming slots', () => { + it('python: pascal types, snake members that report a rename, screaming consts', () => { + const py = new PythonPrinter(); + expect(py.typeName('order-item')).toBe('OrderItem'); + expect(py.memberName('petType')).toEqual({ identifier: 'pet_type', renamed: true }); + expect(py.memberName('class')).toEqual({ identifier: 'class_', renamed: true }); + expect(py.constName('in-progress')).toBe('IN_PROGRESS'); + expect(py.identifiers(['id', 'id'], ['body'])).toEqual(['id', 'id_2']); + }); + + it('go: the digit-leading N rule — a `_` prefix would make the field unexported', () => { + const go = new GoPrinter(); + expect(go.typeName('3ds')).toBe('N3ds'); + expect(exported('order-item')).toBe('OrderItem'); + expect(go.identifiers(['get-user', 'getUser'])).toEqual(['GetUser', 'GetUser2']); + expect(go.packageName('My API!')).toBe('myapi'); + expect(go.packageName('42')).toBe('client'); + }); + + it('php and typescript keep their conventions', () => { + const php = new PhpPrinter(); + expect(php.typeName('order item')).toBe('OrderItem'); + expect(php.memberName('list')).toBe('list_'); + const ts = new TypeScriptPrinter(); + expect(ts.identifier('foo(){};evil()')).toBe('foo_____evil__'); + expect(ts.key('valid')).toBe('valid'); + expect(ts.key('not-valid')).toBe('"not-valid"'); + expect(ts.identifiers(['a-b', 'a.b'])).toEqual(['a_b', 'a_b_2']); + }); +}); + +describe('string slots — each language a real policy, not JSON by coincidence', () => { + const HOSTILE = 'it\'s "x"\n\t\\ €😀'; + + it('python: escapes controls, keeps non-ASCII raw, spells a lone surrogate', () => { + const py = new PythonPrinter(); + expect(py.string(HOSTILE)).toBe('"it\'s \\"x\\"\\n\\t\\\\ €😀"'); + expect(py.string('\u0000')).toBe('"\\x00"'); + expect(py.string('\ud83d')).toBe('"\\ud83d"'); // lone surrogate stays representable + }); + + it('go: same shape, but a lone surrogate has no Go spelling and becomes U+FFFD', () => { + const go = new GoPrinter(); + expect(go.string(HOSTILE)).toBe('"it\'s \\"x\\"\\n\\t\\\\ €😀"'); + expect(go.string('\ud83d')).toBe('"\\uFFFD"'); + }); + + it('typescript: the merged, stricter policy — U+2028/29 AND breakouts', () => { + const ts = new TypeScriptPrinter(); + expect(ts.string('a\u2028b')).toBe('"a\\u2028b"'); + expect(ts.string('')).toBe('"\\u003C/script\\u003E"'); + }); + + it('php: quotes and backslashes, single-quoted', () => { + expect(new PhpPrinter().string("it's \\")).toBe("'it\\'s \\\\'"); + }); +}); + +describe('doc slots', () => { + it('python: one-line and multi-line docstring forms', () => { + const py = new PythonPrinter(); + py.doc('One line.'); + py.doc('First.\n\nSecond.'); + expect(py.toString()).toBe('"""One line."""\n"""First.\n\nSecond.\n"""\n'); + }); + + it('go: consecutive blank comment lines collapse, the way gofmt rewrites them', () => { + const go = new GoPrinter(); + go.doc('Thing', 'Summary.\n\n\n\nMore.'); + expect(go.toString()).toBe('// Thing — Summary.\n//\n// More.\n'); + }); + + it('php: the @tag form when tags exist, one line otherwise', () => { + const php = new PhpPrinter(); + php.doc('items', 'The items.', ['@return array']); + expect(php.toString()).toContain(' * @return array'); + }); + + it('typescript: a star-slash in spec text cannot terminate the comment', () => { + const ts = new TypeScriptPrinter(); + ts.doc('evil */ alert(1) /*'); + expect(ts.toString()).toContain('evil *\\/ alert(1) /*'); + }); +}); + +describe('layout', () => { + it('go: toString applies column alignment and the gofmt whitespace shape', () => { + const go = new GoPrinter(); + go.block( + 'type X struct {', + () => { + go.line('Id int64 `json:"id"`'); + go.line('LongerName string `json:"longerName"`'); + }, + '}' + ); + go.blank(); + go.blank(); + const out = go.toString(); + expect(out).toContain('\tId int64 `json:"id"`'); + expect(out).toContain('\tLongerName string `json:"longerName"`'); + expect(out.endsWith('}\n')).toBe(true); // trailing blanks trimmed + }); +}); diff --git a/packages/client-generator/src/printers/go.ts b/packages/client-generator/src/printers/go.ts new file mode 100644 index 0000000000..098b018df9 --- /dev/null +++ b/packages/client-generator/src/printers/go.ts @@ -0,0 +1,213 @@ +// The Go syntax printer (ADR-0021). Go's extensions carry knowledge that must not be +// re-derived: `typeName`/`memberName` apply the digit-leading `N` rule (a `_` prefix +// means UNexported, so `encoding/json` would silently skip the field), and `layout` is +// applied by `toString()` because CI commonly runs `gofmt -l` and fails on any file it +// would reformat — column padding cannot be computed line-by-line, since the width for +// the first field depends on the longest field in a run that has not been emitted yet. + +import { identifierFor, RESERVED_WORDS } from '../authoring/naming.js'; +import { Printer } from '../authoring/printer.js'; +import { docText } from '../authoring/schema.js'; + +const GO = RESERVED_WORDS.go; + +export class GoPrinter extends Printer { + constructor() { + super('\t'); + } + + /** An exported type name: PascalCase, with the digit-leading `N` rule. */ + typeName(name: string): string { + return exported(name); + } + + /** An exported field/method name — same rule as `typeName`; Go has one namespace. */ + memberName(name: string): string { + return exported(name); + } + + /** A local/argument name: camelCase, keyword-safe. */ + identifier(name: string): string { + return identifierFor(name, { style: 'camel', reserved: GO }); + } + + /** Exported names made unique among themselves and the caller's taken set (`Id`, `Id2`). */ + identifiers(names: readonly string[], taken?: Iterable): string[] { + const used = new Set(taken ?? []); + return names.map((name) => { + const base = exported(name); + let ident = base; + for (let suffix = 2; used.has(ident); suffix++) ident = `${base}${suffix}`; + used.add(ident); + return ident; + }); + } + + /** A package clause name: lower-case letters and digits only, never empty. */ + packageName(name: string): string { + const cleaned = name.toLowerCase().replace(/[^a-z0-9]/g, ''); + return cleaned === '' || /^[0-9]/.test(cleaned) ? 'client' : cleaned; + } + + /** + * A double-quoted Go string literal for any spec-supplied text. Controls are escaped; + * a lone surrogate (a JS string can carry one) has no Go spelling — `\uD800` is an + * invalid code point to the compiler — so it becomes U+FFFD; everything else, + * non-ASCII included, is written as itself, because generated files are UTF-8. + */ + string(value: string): string { + let out = '"'; + for (const char of value) { + const code = char.codePointAt(0)!; + if (char === '\\') out += '\\\\'; + else if (char === '"') out += '\\"'; + else if (char === '\n') out += '\\n'; + else if (char === '\r') out += '\\r'; + else if (char === '\t') out += '\\t'; + else if (code < 0x20 || code === 0x7f) out += `\\x${code.toString(16).padStart(2, '0')}`; + else if (code >= 0xd800 && code <= 0xdfff) out += '\\uFFFD'; + else out += char; + } + return out + '"'; + } + + /** JSON-ish data as a Go expression (`map[string]any` / `[]any` composites). */ + literal(value: unknown): string { + if (value === null || value === undefined) return 'nil'; + if (typeof value === 'boolean' || typeof value === 'number') return String(value); + if (typeof value === 'string') return this.string(value); + if (Array.isArray(value)) { + return `[]any{${value.map((item) => this.literal(item)).join(', ')}}`; + } + const entries = Object.entries(value as Record) + .map(([key, entry]) => `${this.string(key)}: ${this.literal(entry)}`) + .join(', '); + return `map[string]any{${entries}}`; + } + + /** A `//` line comment. */ + comment(text: string): this { + for (const line of docText(text)) this.line(line === '' ? '//' : `// ${line}`); + return this; + } + + /** A doc comment: `// Name — summary`, blank lines collapsed the way gofmt rewrites them. */ + doc(name: string, description?: string): this { + const lines = docText(description); + if (lines.length === 0) return this; + this.line(`// ${name} — ${lines[0]}`); + let previousWasBlank = false; + for (const line of lines.slice(1)) { + if (line === '') { + if (!previousWasBlank) this.line('//'); + previousWasBlank = true; + continue; + } + this.line(`// ${line}`); + previousWasBlank = false; + } + return this; + } + + /** gofmt-clean text: column alignment plus the whitespace shape gofmt produces. */ + layout(source: string): string { + return gofmtShape(alignGoColumns(source)); + } + + override toString(): string { + return this.layout(super.toString()); + } +} + +/** An exported Go identifier: PascalCase, digit-leading names get `N` (never `_`). */ +export function exported(name: string): string { + const ident = identifierFor(name, { style: 'pascal', reserved: GO }); + return ident.startsWith('_') ? `N${ident.slice(1)}` : ident; +} + +/** + * The whitespace shape gofmt produces: never more than one blank line, and exactly one + * trailing newline. Both entry points below run through it, so the models view is as + * gofmt-clean as the full client. + */ +function gofmtShape(source: string): string { + return `${source.replace(/\n{3,}/g, '\n\n').trimEnd()}\n`; +} + +/** + * Align columns the way gofmt does, so the emitted file is already idiomatic and a + * `gofmt` run is a no-op. gofmt pads with spaces inside a contiguous run of similar + * lines: struct fields align their type and tag columns, `const`/`var` entries align + * their type and `=`. A line that doesn't fit the shape (a comment, a blank line, a + * type containing spaces) ends the run, exactly like gofmt's tabwriter. + */ +function alignGoColumns(source: string): string { + const lines = source.split('\n'); + const out = [...lines]; + // `\tName Type` optionally followed by a `json:"…"` tag, `\tName Type = value`, or a + // quoted map key. A statement starting with a Go keyword (`case "x":`, `return y`) is + // NOT a declaration and must never be padded. + const FIELD = /^(\t+)([A-Za-z_]\w*) (\S+)( `[^`]*`)?$/; + const CONST = /^(\t+)([A-Za-z_]\w*) (\S+) = (.+)$/; + const ENTRY = /^(\t+)("(?:[^"\\]|\\.)*":) (.+)$/; + + const flush = (run: Array<{ index: number; parts: string[]; indent: string }>): void => { + if (run.length < 2) return; + const widths: number[] = []; + for (const { parts } of run) { + parts.forEach((part, column) => { + // The last column never needs padding. + if (column < parts.length - 1) widths[column] = Math.max(widths[column] ?? 0, part.length); + }); + } + for (const { index, parts, indent } of run) { + const padded = parts.map((part, column) => + column < parts.length - 1 ? part.padEnd(widths[column] ?? 0) : part + ); + out[index] = indent + padded.join(' ').trimEnd(); + } + }; + + let run: Array<{ index: number; parts: string[]; indent: string }> = []; + let runKind: 'field' | 'const' | 'entry' | undefined; + lines.forEach((line, index) => { + const entryMatch = ENTRY.exec(line); + const constMatch = entryMatch === null ? CONST.exec(line) : null; + const fieldCandidate = entryMatch === null && constMatch === null ? FIELD.exec(line) : null; + // `case`, `return`, `var`, … start statements, not declarations. + const fieldMatch = + fieldCandidate !== null && !GO.has(fieldCandidate[2]) ? fieldCandidate : null; + const kind = + entryMatch !== null + ? 'entry' + : constMatch !== null + ? 'const' + : fieldMatch !== null + ? 'field' + : undefined; + if (kind === undefined || kind !== runKind) { + flush(run); + run = []; + runKind = kind; + } + if (entryMatch !== null) { + run.push({ index, indent: entryMatch[1], parts: [entryMatch[2], entryMatch[3]] }); + return; + } + if (constMatch !== null) { + run.push({ + index, + indent: constMatch[1], + parts: [constMatch[2], constMatch[3], '=', constMatch[4]], + }); + return; + } + if (fieldMatch !== null) { + const parts = [fieldMatch[2], fieldMatch[3]]; + if (fieldMatch[4] !== undefined) parts.push(fieldMatch[4].trimStart()); + run.push({ index, indent: fieldMatch[1], parts }); + } + }); + flush(run); + return out.join('\n'); +} diff --git a/packages/client-generator/src/printers/index.ts b/packages/client-generator/src/printers/index.ts new file mode 100644 index 0000000000..be4a555680 --- /dev/null +++ b/packages/client-generator/src/printers/index.ts @@ -0,0 +1,9 @@ +// The four language printers (ADR-0021): the common `Printer` owns structure, each of +// these owns one language's syntax. They fill the same slots — `typeName`, `memberName`, +// `identifier`, `identifiers`, `string`, `literal`, `comment`, `doc`, a baked-in indent +// unit, and (where the language demands one) a `layout` pass applied by `toString()`. + +export { GoPrinter, exported } from './go.js'; +export { PhpPrinter } from './php.js'; +export { PythonPrinter } from './python.js'; +export { TypeScriptPrinter } from './typescript.js'; diff --git a/packages/client-generator/src/printers/php.ts b/packages/client-generator/src/printers/php.ts new file mode 100644 index 0000000000..b3a6d7e71b --- /dev/null +++ b/packages/client-generator/src/printers/php.ts @@ -0,0 +1,70 @@ +// The PHP syntax printer (ADR-0021). PHP's extension: `doc` takes `@tag` lines, because +// `array` and `\Generator` erase element types — the docblock carries what they hold. + +import { identifierFor, RESERVED_WORDS, uniqueIdentifiers } from '../authoring/naming.js'; +import { Printer } from '../authoring/printer.js'; +import { docText } from '../authoring/schema.js'; + +const PHP = RESERVED_WORDS.php; + +export class PhpPrinter extends Printer { + constructor() { + super(' '); + } + + /** A class/enum name: PascalCase, keyword-safe. */ + typeName(name: string): string { + return identifierFor(name, { style: 'pascal', reserved: PHP }); + } + + /** A property/method name: camelCase, keyword-safe. */ + memberName(name: string): string { + return identifierFor(name, { style: 'camel', reserved: PHP }); + } + + /** A variable/argument name (without the `$`). */ + identifier(name: string): string { + return identifierFor(name, { style: 'camel', reserved: PHP }); + } + + /** Names made unique among themselves and the caller's taken set (`id`, `id2`). */ + identifiers(names: readonly string[], taken?: Iterable): string[] { + return uniqueIdentifiers(names, { style: 'camel', reserved: PHP, taken }); + } + + /** `'…'` with backslashes and quotes escaped — safe for any spec-supplied text. */ + string(value: string): string { + return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; + } + + /** JSON-ish data as a PHP expression (arrays for both lists and maps). */ + literal(value: unknown): string { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'boolean' || typeof value === 'number') return String(value); + if (typeof value === 'string') return this.string(value); + if (Array.isArray(value)) return `[${value.map((item) => this.literal(item)).join(', ')}]`; + const entries = Object.entries(value as Record) + .map(([key, entry]) => `${this.string(key)} => ${this.literal(entry)}`) + .join(', '); + return `[${entries}]`; + } + + /** A `//` line comment. */ + comment(text: string): this { + for (const line of docText(text)) this.line(line === '' ? '//' : `// ${line}`); + return this; + } + + /** A docblock: one line without tags, the `@tag` form with them. */ + doc(name: string, description?: string, tags: string[] = []): this { + const lines = docText(description); + if (lines.length === 0 && tags.length === 0) return this; + const summary = lines.length === 0 ? name : `${name} — ${lines.join(' ')}`; + if (tags.length === 0) return this.line(`/** ${summary} */`); + this.line('/**'); + this.line(` * ${summary}`); + this.line(' *'); + for (const tag of tags) this.line(` * ${tag}`); + return this.line(' */'); + } +} diff --git a/packages/client-generator/src/printers/python.ts b/packages/client-generator/src/printers/python.ts new file mode 100644 index 0000000000..3961128f86 --- /dev/null +++ b/packages/client-generator/src/printers/python.ts @@ -0,0 +1,94 @@ +// The Python syntax printer (ADR-0021): structure from the common `Printer`, syntax — +// identifier safety, string escaping, literal rendering, comment and docstring form — +// owned here. The generator owns shape (classes, signatures, field lists) as template +// literals; the test for what belongs on the printer is "is there exactly one right answer?" + +import { identifierFor, RESERVED_WORDS, uniqueIdentifiers } from '../authoring/naming.js'; +import { Printer } from '../authoring/printer.js'; +import { docText } from '../authoring/schema.js'; + +const PY = RESERVED_WORDS.python; + +export class PythonPrinter extends Printer { + constructor() { + super(' '); + } + + /** A class name: PascalCase, keyword-safe. */ + typeName(name: string): string { + return identifierFor(name, { style: 'pascal', reserved: PY }); + } + + /** A field/parameter name, reporting a rename so the caller can record the wire name. */ + memberName(name: string): { identifier: string; renamed: boolean } { + const identifier = identifierFor(name, { style: 'snake', reserved: PY }); + return { identifier, renamed: identifier !== name }; + } + + /** A local/argument name: snake_case, keyword-safe. */ + identifier(name: string): string { + return identifierFor(name, { style: 'snake', reserved: PY }); + } + + /** Names made unique among themselves and the caller's taken set (`id`, `id_2`). */ + identifiers(names: readonly string[], taken?: Iterable): string[] { + return uniqueIdentifiers(names, { style: 'snake', reserved: PY, taken }); + } + + /** A module-level constant name: SCREAMING_SNAKE. */ + constName(name: string): string { + return identifierFor(name, { style: 'screaming', reserved: PY }); + } + + /** + * A double-quoted Python string literal for any spec-supplied text. Controls are + * escaped; a lone surrogate (a JS string can carry one) stays representable as its + * `\uXXXX` escape; everything else — non-ASCII included — is written as itself, + * because generated files are UTF-8. + */ + string(value: string): string { + let out = '"'; + for (const char of value) { + const code = char.codePointAt(0)!; + if (char === '\\') out += '\\\\'; + else if (char === '"') out += '\\"'; + else if (char === '\n') out += '\\n'; + else if (char === '\r') out += '\\r'; + else if (char === '\t') out += '\\t'; + else if (code < 0x20 || code === 0x7f) out += `\\x${code.toString(16).padStart(2, '0')}`; + else if (code >= 0xd800 && code <= 0xdfff) out += `\\u${code.toString(16).padStart(4, '0')}`; + else out += char; + } + return out + '"'; + } + + /** JSON-ish data as a Python expression (dicts/lists/strings/numbers/bools/None). */ + literal(value: unknown): string { + if (value === null || value === undefined) return 'None'; + if (value === true) return 'True'; + if (value === false) return 'False'; + if (typeof value === 'number') return String(value); + if (typeof value === 'string') return this.string(value); + if (Array.isArray(value)) return `[${value.map((item) => this.literal(item)).join(', ')}]`; + const entries = Object.entries(value as Record) + .map(([key, entry]) => `${this.string(key)}: ${this.literal(entry)}`) + .join(', '); + return `{${entries}}`; + } + + /** A `#` line comment (multi-line text becomes one `#` line per line). */ + comment(text: string): this { + for (const line of docText(text)) this.line(line === '' ? '#' : `# ${line}`); + return this; + } + + /** A docstring: Python's one-line and multi-line forms differ, and this owns the rule. */ + doc(description?: string): this { + const lines = docText(description); + if (lines.length === 0) return this; + if (lines.length === 1) return this.line(`"""${lines[0]}"""`); + this.line(`"""${lines[0]}`); + for (const line of lines.slice(1)) this.line(line); + return this.line('"""'); + } +} diff --git a/packages/client-generator/src/printers/typescript.ts b/packages/client-generator/src/printers/typescript.ts new file mode 100644 index 0000000000..43190ca109 --- /dev/null +++ b/packages/client-generator/src/printers/typescript.ts @@ -0,0 +1,68 @@ +// The TypeScript syntax printer (ADR-0021). TypeScript's extension is `key(name)` — a +// bare-or-quoted object key; no other output language has quotable keys. Its `string` +// carries the merged escaping policy: JSON escaping plus U+2028/U+2029 (line terminators +// in JS source) plus `<`/`>` (a `` breakout when output lands in an inline +// script) — previously two escapers with different protections, split by import site. + +import { Printer } from '../authoring/printer.js'; +import { docText } from '../authoring/schema.js'; +import { isSafeIdentifier, sanitizeIdentifier, uniqueIdent } from '../emitters/identifier.js'; +import { pascalCase } from '../emitters/support.js'; +import { codeLiteral, sanitizeCodeString } from '../emitters/ts-literal.js'; + +export class TypeScriptPrinter extends Printer { + constructor() { + super(' '); + } + + /** A type name: PascalCase over an already-sanitized name (the IR coerces op names). */ + typeName(name: string): string { + return pascalCase(sanitizeIdentifier(name)); + } + + /** A member (binding) name: sanitized, keyword-safe. */ + memberName(name: string): string { + return sanitizeIdentifier(name); + } + + /** A local/argument name: sanitized, keyword-safe. */ + identifier(name: string): string { + return sanitizeIdentifier(name); + } + + /** Names made unique among themselves and the caller's taken set (`id`, `id_2`). */ + identifiers(names: readonly string[], taken?: Iterable): string[] { + const used = new Set(taken ?? []); + return names.map((name) => uniqueIdent(name, used)); + } + + /** An object key: bare when it is a valid non-reserved identifier, quoted otherwise. */ + key(name: string): string { + return isSafeIdentifier(name) ? name : this.string(name); + } + + /** A string literal that cannot escape the code context it lands in. */ + string(value: string): string { + return sanitizeCodeString(value); + } + + /** JSON-ish data as TypeScript source text. */ + literal(value: unknown): string { + return codeLiteral(value); + } + + /** A `//` line comment. */ + comment(text: string): this { + for (const line of docText(text)) this.line(line === '' ? '//' : `// ${line}`); + return this; + } + + /** A JSDoc block; a star-slash in spec text is escaped so it cannot terminate it. */ + doc(description?: string): this { + const lines = docText(description); + if (lines.length === 0) return this; + this.line('/**'); + for (const line of lines) this.line(line === '' ? ' *' : ` * ${line.replace(/\*\//g, '*\\/')}`); + return this.line(' */'); + } +} From 0bfd32a8dabf06f00d12514577a45acc20971994 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 18:03:27 +0300 Subject: [PATCH 13/35] fix(client-generator): python and go string literals get a real escaping policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both languages built string literals with `JSON.stringify` — 18 sites in python, 28 in go — relying on JSON escaping being close enough to each language's syntax. It is not, at the edges that matter: JSON encodes an astral character (any emoji) as a surrogate PAIR, which Go rejects outright (`\uD83C` is an invalid code point to the compiler) and Python parses as two lone surrogates instead of the character. A description or parameter name with an emoji broke the generated Go module and corrupted the Python one. Every site now goes through the printer's `string()` — controls escaped, non-ASCII written as itself (generated files are UTF-8), a lone surrogate spelled `\uXXXX` in Python and replaced with U+FFFD in Go, which has no spelling for one. `pythonLiteral` delegates to the printer's `literal`, which also fixes booleans in `Literal[...]` and enum members: JSON's lowercase `true` was never valid Python. Output for ordinary specs is byte-identical (verified on two fixtures per language); a spec with `"mood 🎉"` parameter and emoji enum values now py_compiles and `go build`s. --- .../src/generators/go/index.ts | 59 ++++++++++--------- .../src/generators/python/index.ts | 44 ++++++-------- 2 files changed, 48 insertions(+), 55 deletions(-) diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index 7b490a13c1..77ed56320f 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -37,6 +37,9 @@ import type { ServerModel, } from '../../intermediate-representation/model.js'; import { exported, GoPrinter } from '../../printers/go.js'; + +// One escaping policy for every Go string literal this generator prints. +const naming = new GoPrinter(); import type { CodeSample, Generator, SampleContext } from '../types.js'; const GO = RESERVED_WORDS.go; @@ -184,7 +187,7 @@ function renderGoModelBodies(model: ApiModel, dateType: DateType): string { for (let n = 2; used.has(base + suffix); n++) suffix = String(n); used.add(base + suffix); const member = exported(name) + base + suffix; - printer.line(`${member} ${exported(name)} = ${JSON.stringify(value)}`); + printer.line(`${member} ${exported(name)} = ${naming.literal(value)}`); }); }, ')' @@ -238,7 +241,7 @@ function renderGoModelBodies(model: ApiModel, dateType: DateType): string { // 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.block(`case ${naming.string(entry.value)}:`, () => { printer.line(`var value ${exported(entry.schemaName)}`); printer.line('err := json.Unmarshal(data, &value)'); printer.line('return value, err'); @@ -267,8 +270,8 @@ function goSecurityLiteral(op: OperationModel, model: ApiModel): string | undefi const alternatives = securityRequirements(op, model).map((alternative) => alternative.map((spec) => spec.kind === 'apiKey' - ? `{Scheme: ${JSON.stringify(spec.scheme)}, Kind: "apiKey", Name: ${JSON.stringify(spec.name)}, In: ${JSON.stringify(spec.in)}}` - : `{Scheme: ${JSON.stringify(spec.scheme)}, Kind: ${JSON.stringify(spec.kind)}}` + ? `{Scheme: ${naming.string(spec.scheme)}, Kind: "apiKey", Name: ${naming.string(spec.name)}, In: ${naming.string(spec.in)}}` + : `{Scheme: ${naming.string(spec.scheme)}, Kind: ${naming.string(spec.kind)}}` ) ); if (alternatives.length === 0) return undefined; @@ -329,12 +332,12 @@ function stripHeader(source: string): string { /** 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)}`] : []), + `Style: ${naming.string(rule.style)}`, + ...(rule.param !== undefined ? [`Param: ${naming.string(rule.param)}`] : []), + ...(rule.nextCursor !== undefined ? [`NextCursor: ${naming.string(rule.nextCursor)}`] : []), + ...(rule.hasMore !== undefined ? [`HasMore: ${naming.string(rule.hasMore)}`] : []), + ...(rule.limitParam !== undefined ? [`LimitParam: ${naming.string(rule.limitParam)}`] : []), + ...(rule.items !== undefined ? [`Items: ${naming.string(rule.items)}`] : []), ]; return `&PaginationSpec{${fields.join(', ')}}`; } @@ -448,7 +451,7 @@ function writeGoMethod( () => { 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(`op := operations[${naming.string(op.specName ?? op.name)}]`); printer.line('authHeaders, query := resolveAuth(op.Security, c.config.Auth)'); if (hasParams) { printer.block( @@ -468,14 +471,14 @@ function writeGoMethod( `for _, item := range *params.${field} {`, () => { printer.line( - `query.Add(${JSON.stringify(param.name)}, ${goQueryFormat('item', elementType)})` + `query.Add(${naming.string(param.name)}, ${goQueryFormat('item', elementType)})` ); }, '}' ); } else { printer.line( - `query.Set(${JSON.stringify(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema, dateType))})` + `query.Set(${naming.string(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema, dateType))})` ); } }, @@ -487,7 +490,7 @@ function writeGoMethod( ); } const pathDict = pathArgs - .map(({ param, go, type }) => `${JSON.stringify(param.name)}: ${goQueryFormat(go, type)}`) + .map(({ param, go, type }) => `${naming.string(param.name)}: ${goQueryFormat(go, type)}`) .join(', '); printer.line( `requestURL := buildURL(c.config.ServerURL, op.Path, map[string]string{${pathDict}})` @@ -550,7 +553,7 @@ function writeGoMethod( '}' ); specFields.push('Body: bytes.NewReader(payload)'); - specFields.push(`ContentType: ${JSON.stringify(op.requestBody.contentType)}`); + specFields.push(`ContentType: ${naming.string(op.requestBody.contentType)}`); } printer.line(`resp, err := send(ctx, &c.config, requestSpec{${specFields.join(', ')}})`); printer.block( @@ -577,7 +580,7 @@ function writeGoMethod( ); for (const planned of headerPlan) { printer.line( - `headers.${planned.field} = ${planned.helper}(resp.Header, ${JSON.stringify(planned.name)})` + `headers.${planned.field} = ${planned.helper}(resp.Header, ${naming.string(planned.name)})` ); } printer.line(returnType === undefined ? 'return headers, nil' : 'return out, headers, nil'); @@ -617,7 +620,7 @@ function writeGoPaginationWrappers( ].join(', '); const writeCallClosure = () => { - printer.line(`op := operations[${JSON.stringify(op.specName ?? op.name)}]`); + printer.line(`op := operations[${naming.string(op.specName ?? op.name)}]`); printer.line('base := url.Values{}'); if (hasParams) { printer.block( @@ -629,7 +632,7 @@ function writeGoPaginationWrappers( `if params.${field} != nil {`, () => { printer.line( - `base.Set(${JSON.stringify(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema, dateType))})` + `base.Set(${naming.string(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema, dateType))})` ); }, '}' @@ -657,7 +660,7 @@ function writeGoPaginationWrappers( '}' ); const pathDict = pathArgs - .map(({ param, go, type }) => `${JSON.stringify(param.name)}: ${goQueryFormat(go, type)}`) + .map(({ param, go, type }) => `${naming.string(param.name)}: ${goQueryFormat(go, type)}`) .join(', '); printer.line( `requestURL := buildURL(c.config.ServerURL, op.Path, map[string]string{${pathDict}})` @@ -785,7 +788,7 @@ function writeGoPaginationWrappers( function serverUrlExpression(server: ServerModel): string { const parts = serverUrlParts(server).map((part) => part.kind === 'literal' - ? JSON.stringify(part.value) + ? naming.string(part.value) : identifierFor(part.name, { style: 'camel', reserved: GO }) ); return parts.join(' + '); @@ -806,11 +809,11 @@ function writeGoServers(printer: GoPrinter, model: ApiModel): void { const defaults = server.variables .map( (variable) => - `${identifierFor(variable.name, { style: 'camel', reserved: GO })} default: ${JSON.stringify(variable.default)}` + `${identifierFor(variable.name, { style: 'camel', reserved: GO })} default: ${naming.string(variable.default)}` ) .join(', '); printer.line( - `// ${name} returns the ${JSON.stringify(server.description ?? server.url)} base URL${defaults === '' ? '.' : ` (${defaults}).`}` + `// ${name} returns the ${naming.string(server.description ?? server.url)} base URL${defaults === '' ? '.' : ` (${defaults}).`}` ); printer.block( `func ${name}(${params.join(', ')}) string {`, @@ -861,7 +864,7 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { 'strings', 'time', ]) { - printer.line(JSON.stringify(spec)); + printer.line(naming.string(spec)); } }, ')' @@ -895,13 +898,13 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { const security = goSecurityLiteral(op, model); const rule = paginationRules.get(ident); const fields = [ - `ID: ${JSON.stringify(id)}`, - `Method: ${JSON.stringify(op.method.toUpperCase())}`, - `Path: ${JSON.stringify(op.path)}`, + `ID: ${naming.string(id)}`, + `Method: ${naming.string(op.method.toUpperCase())}`, + `Path: ${naming.string(op.path)}`, ...(security !== undefined ? [`Security: ${security}`] : []), ...(rule !== undefined ? [`Pagination: ${goPaginationLiteral(rule)}`] : []), ]; - printer.line(`${JSON.stringify(id)}: {${fields.join(', ')}},`); + printer.line(`${naming.string(id)}: {${fields.join(', ')}},`); } }, '}' @@ -942,7 +945,7 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { 'if config.ServerURL == "" {', () => { printer.line( - `config.ServerURL = ${JSON.stringify(emit.serverUrl ?? model.serverUrl ?? '')}` + `config.ServerURL = ${naming.string(emit.serverUrl ?? model.serverUrl ?? '')}` ); }, '}' diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index edde70c457..65d33d477b 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -71,7 +71,7 @@ export function pythonType(schema: SchemaModel, dateType: DateType = 'string'): case 'ref': return className(schema.name); case 'literal': - return `Literal[${JSON.stringify(schema.value)}]`; + return `Literal[${naming.literal(schema.value)}]`; case 'enum': // Anonymous (inline) enums keep the wire scalar; only NAMED enums get classes. return { string: 'str', integer: 'int', number: 'float', boolean: 'bool' }[schema.scalar]; @@ -195,10 +195,10 @@ function writeDataclass( 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 alias = renamed && pydantic ? `alias=${naming.string(property.name)}` : undefined; const baseType = pinned?.property === property.name - ? `Literal[${JSON.stringify(pinned.value)}]` + ? `Literal[${naming.literal(pinned.value)}]` : pythonType(property.schema, dateType); if (property.required) { const value = alias === undefined ? '' : ` = Field(${alias})`; @@ -212,7 +212,7 @@ function writeDataclass( 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(', '); + const entries = fieldMap.map(([py, wire]) => `"${py}": ${naming.string(wire)}`).join(', '); printer.line(`_field_map: ClassVar[Dict[str, str]] = {${entries}}`); } }); @@ -265,7 +265,7 @@ export function renderPythonModels( printer.block(`class ${className(name)}(${base}):`, () => { printer.doc(schema.description); asEnum.values.forEach((value, index) => { - printer.line(`${asEnum.memberNames[index]} = ${JSON.stringify(value)}`); + printer.line(`${asEnum.memberNames[index]} = ${naming.literal(value)}`); }); }); printer.blank(); @@ -301,7 +301,7 @@ export function renderPythonModels( const union = field === undefined ? pythonType(schema, dateType) - : `Annotated[${pythonType(schema, dateType)}, Field(discriminator=${JSON.stringify(field)})]`; + : `Annotated[${pythonType(schema, dateType)}, Field(discriminator=${naming.string(field)})]`; printer.line(`${className(name)} = ${union}`); printer.blank(); }); @@ -313,7 +313,7 @@ export function renderPythonModels( /** The server URL as a Python expression: literals concatenated with declared-variable args. */ function serverUrlExpression(server: ServerModel): string { const parts = serverUrlParts(server).map((part) => - part.kind === 'literal' ? JSON.stringify(part.value) : fieldName(part.name).python + part.kind === 'literal' ? naming.string(part.value) : fieldName(part.name).python ); return parts.join(' + '); } @@ -336,8 +336,7 @@ function writePythonServers(printer: PythonPrinter, model: ApiModel): void { 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)}` + (variable) => `${fieldName(variable.name).python}: str = ${naming.string(variable.default)}` ); if (index > 0) printer.blank(); printer.line('@staticmethod'); @@ -362,10 +361,10 @@ function discriminatorRegistrations(model: ApiModel, annotated: Set): st const cases = discriminatorCases(schema, model); if (cases === undefined) continue; const mapping = cases.cases - .map((entry) => `${JSON.stringify(entry.value)}: ${className(entry.schemaName)}`) + .map((entry) => `${naming.string(entry.value)}: ${className(entry.schemaName)}`) .join(', '); lines.push( - `DISCRIMINATORS[${className(name)}] = (${JSON.stringify(cases.property)}, {${mapping}})` + `DISCRIMINATORS[${className(name)}] = (${naming.string(cases.property)}, {${mapping}})` ); } return lines; @@ -373,16 +372,7 @@ function discriminatorRegistrations(model: ApiModel, annotated: Set): st /** 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}}`; + return naming.literal(value); } /** Every operation with its collision-free snake_case Python method name. */ @@ -423,7 +413,7 @@ function envelopeHeaderSpecs(op: OperationModel, model: ApiModel): string { 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 `(${naming.string(header.name)}, ${naming.string(key)}, ${naming.string(type)})`; }); return `[${specs.join(', ')}]`; } @@ -495,11 +485,11 @@ function writeMethod( 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})`); + printer.line(`params[${naming.string(param.name)}] = encode(${python})`); }); } const pathDict = pathArgs - .map(({ param, python }) => `${JSON.stringify(param.name)}: ${python}`) + .map(({ param, python }) => `${naming.string(param.name)}: ${python}`) .join(', '); printer.line(`url = build_url(self._server_url, op["path"], {${pathDict}})`); if (sse !== undefined) { @@ -599,7 +589,7 @@ function writePaginationWrappers( 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})`); + printer.line(`base[${naming.string(param.name)}] = encode(${python})`); }); } const prefix = isAsync ? 'async def' : 'def'; @@ -607,7 +597,7 @@ function writePaginationWrappers( 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}`) + .map(({ param, python }) => `${naming.string(param.name)}: ${python}`) .join(', '); printer.line(`url = build_url(self._server_url, op["path"], {${pathDict}})`); printer.line( @@ -679,7 +669,7 @@ function writeClientClass( printer.block(`class ${name}:`, () => { printer.doc(`${isAsync ? 'Async ' : ''}client for ${model.title} (${model.version}).`); printer.block( - `def __init__(self, server_url: str = ${JSON.stringify(serverUrl)}, *, ` + + `def __init__(self, server_url: str = ${naming.string(serverUrl)}, *, ` + 'auth: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, ' + 'timeout: Optional[float] = None, retry: Optional[Dict[str, Any]] = None, ' + 'middleware: Optional[List[Any]] = None, idempotency_key: Any = None, ' + From ee9afd08fe2a367ae43ea8f14c4e7b7b8ea7b931 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 20:44:00 +0300 Subject: [PATCH 14/35] fix(client-generator): one TypeScript string escaper, on the stricter policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two escapers existed with different security policies: `codeString` escaped U+2028/U+2029, `sanitizeCodeString` also escaped `<`/`>` to stop a `` breakout when generated output lands in an inline script. Which protection applied depended on which one the caller imported. There is one policy now — the stricter one, owned by `codeString` (the printers' `string()` and `sanitizeCodeString` are the same function) — so `<` and `>` are escaped in the few places that previously left them literal. The cafe fixture's output is unchanged; the injection e2e and the golden snapshots pass as they are. --- .../src/emitters/identifier.ts | 18 ++++++++----- .../src/emitters/ts-literal.ts | 25 ++++--------------- 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/packages/client-generator/src/emitters/identifier.ts b/packages/client-generator/src/emitters/identifier.ts index 9905360e40..cdb8f0634f 100644 --- a/packages/client-generator/src/emitters/identifier.ts +++ b/packages/client-generator/src/emitters/identifier.ts @@ -34,14 +34,20 @@ export function sanitizeIdentifier(name: string): string { } /** - * A double-quoted TS string literal for generated code. `JSON.stringify` alone leaves - * U+2028/U+2029 raw (legal JSON, line terminators in code contexts) — escape them so a - * hostile spec value can never alter the shape of the emitted statement. + * A double-quoted TS string literal for generated code. One policy for the whole + * package — the stricter of the two that used to exist: U+2028/U+2029 (line terminators + * in JS source) AND `<`/`>` (a `` breakout when output lands in an inline + * script). Which protection applied used to depend on which escaper the caller imported. */ +const CODE_UNSAFE: Record = { + '<': '\\u003C', + '>': '\\u003E', + '\u2028': '\\u2028', + '\u2029': '\\u2029', +}; + export function codeString(value: string): string { - return JSON.stringify(value) - .replace(/\u2028/g, '\\u2028') - .replace(/\u2029/g, '\\u2029'); + return JSON.stringify(value).replace(/[<>\u2028\u2029]/g, (char) => CODE_UNSAFE[char]); } /** diff --git a/packages/client-generator/src/emitters/ts-literal.ts b/packages/client-generator/src/emitters/ts-literal.ts index 86008af0b9..9a2edea6a6 100644 --- a/packages/client-generator/src/emitters/ts-literal.ts +++ b/packages/client-generator/src/emitters/ts-literal.ts @@ -2,29 +2,14 @@ // keys stay bare when they pass the identifier GRAMMAR (reserved words are legal // object-literal keys), quoted otherwise. -import { isIdentifier } from './identifier.js'; +import { codeString, 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]); -} +/** The one string-literal policy, under this module's historical name. */ +export const sanitizeCodeString = codeString; /** A JSON-ish value as TypeScript source text. */ export function codeLiteral(value: unknown): string { - if (typeof value === 'string') return sanitizeCodeString(value); + if (typeof value === 'string') return codeString(value); if (typeof value === 'boolean' || value === null) return String(value); if (typeof value === 'number') return String(value); if (Array.isArray(value)) { @@ -32,7 +17,7 @@ export function codeLiteral(value: unknown): string { } const entries = Object.entries(value as Record).map( ([key, entryValue]) => - `${isIdentifier(key) ? key : sanitizeCodeString(key)}: ${codeLiteral(entryValue)}` + `${isIdentifier(key) ? key : codeString(key)}: ${codeLiteral(entryValue)}` ); return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`; } From c85e38171a859f5bf32aad3ed23b89aa51070185 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 21:10:20 +0300 Subject: [PATCH 15/35] refactor: resolve pagination once in the pipeline and hand every generator the same verified map --- .../src/__tests__/pipeline-ts-free.test.ts | 2 + .../src/authoring/reference-page.ts | 8 +++- .../src/emitters/__tests__/cli.test.ts | 3 +- .../__tests__/client-assembly.test.ts | 20 ++++++---- .../emitters/__tests__/tanstack-query.test.ts | 39 +++++++++++-------- packages/client-generator/src/emitters/cli.ts | 10 ++--- .../src/emitters/client-assembly.ts | 3 +- .../src/emitters/emit-options.ts | 6 +-- .../src/emitters/tanstack-query.ts | 13 ++----- .../src/generators/__tests__/go.test.ts | 9 ++++- .../src/generators/__tests__/php.test.ts | 16 +++++++- .../src/generators/__tests__/python.test.ts | 33 ++++++++++++++-- .../src/generators/cli/index.ts | 10 ++--- .../src/generators/go/index.ts | 12 +++--- .../src/generators/php/index.ts | 12 +++--- .../src/generators/python/index.ts | 19 ++++----- .../src/generators/tanstack-query/index.ts | 4 +- .../client-generator/src/generators/types.ts | 18 ++++++++- .../src/generators/typescript/index.ts | 4 +- packages/client-generator/src/pipeline.ts | 17 ++++++-- 20 files changed, 167 insertions(+), 91 deletions(-) diff --git a/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts b/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts index 160eba8de4..37b49801c6 100644 --- a/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts +++ b/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts @@ -42,8 +42,10 @@ function staticGraph(entry: string): { files: Set; externals: Set { lang: string; source: string } | undefined; /** The `pagination` config, passed through to `paginationRuleFor`. */ pagination?: Record; + /** Operation names the RUN resolved as paginated — preferred over re-resolving. */ + paginated?: ReadonlySet; }; /** Table-cell-safe text: one line, with pipes and backslashes escaped. */ @@ -135,7 +137,11 @@ function writeOperation(printer: Printer, op: OperationModel, options: Reference // The same three declaration-level facts every SDK reads: `paginationRuleFor` is the // helper the language generators resolve pagination with, and the success content type // is what decides a streaming or a binary response. - if (paginationRuleFor(op, options.pagination)) { + const paginates = + options.paginated !== undefined + ? options.paginated.has(op.name) + : paginationRuleFor(op, options.pagination) !== undefined; + if (paginates) { printer.line('This operation is paginated, so the SDK gives it page and item iterators.'); } if (op.successResponses.some((response) => response.contentType === 'text/event-stream')) { diff --git a/packages/client-generator/src/emitters/__tests__/cli.test.ts b/packages/client-generator/src/emitters/__tests__/cli.test.ts index 2c390ba08d..817dec8fa3 100644 --- a/packages/client-generator/src/emitters/__tests__/cli.test.ts +++ b/packages/client-generator/src/emitters/__tests__/cli.test.ts @@ -2,6 +2,7 @@ import { logger } from '@redocly/openapi-core'; import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; import { commandData, renderCliModule, renderComposedCliEntry } from '../cli.js'; +import { resolveModelPagination } from '../pagination.js'; const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' }; const INT: SchemaModel = { kind: 'scalar', scalar: 'integer' }; @@ -184,7 +185,7 @@ const MODEL: ApiModel = { describe('commandData', () => { it('derives groups from tags, flags from query params, and positionals in path order', () => { - const commands = commandData(MODEL, {}); + const commands = commandData(MODEL, { pagination: resolveModelPagination(MODEL, undefined) }); const list = commands.find((command) => command.name === 'listOrders'); expect(list).toMatchObject({ group: 'Orders', 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 c08fcdfdf7..4cdfb011eb 100644 --- a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts +++ b/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts @@ -3,6 +3,7 @@ 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 { resolveModelPagination } from '../pagination.js'; import { modelWith, namedSchema, operation, param, response, SCALAR } from './fixtures.js'; /** The package arm of the shared emitter. */ @@ -442,9 +443,10 @@ describe('emitClientSingleFile (embed arm)', () => { describe('emitClientSingleFile — pagination', () => { const PAGINATED = modelWith([listOrders, getOrder], { schemas: [...SCHEMAS, ORDER_PAGE] }); const config = { operations: { listOrders: CURSOR_RULE } }; + const pagination = resolveModelPagination(PAGINATED, config); it('threads a config rule into the descriptor and the Ops item member (package arm)', () => { - const out = emit(PAGINATED, { pagination: config }); + const out = emit(PAGINATED, { pagination }); expect(out).toContain( 'pagination: { style: "cursor", param: "cursor", nextCursor: "/nextCursor", items: "/orders" }' ); @@ -457,13 +459,13 @@ describe('emitClientSingleFile — pagination', () => { const model = modelWith([{ ...listOrders, paginationExtension: CURSOR_RULE }, getOrder], { schemas: [...SCHEMAS, ORDER_PAGE], }); - const out = emit(model); + const out = emit(model, { pagination: resolveModelPagination(model, undefined) }); expect(out).toContain('item: Order;'); expect(out).toContain('pagination: { style: "cursor", param: "cursor",'); }); it('the iterators ride the binding, so `.pages`/`.items` need no wrapper', () => { - const out = emit(PAGINATED, { pagination: config }); + const out = emit(PAGINATED, { pagination }); // `listOrders` is the client method itself, which carries `.pages`/`.items` — there is // nothing to re-wrap, and therefore no second argument shape to get wrong. expect(out).toContain('export const { listOrders, getOrder } = client;'); @@ -472,7 +474,7 @@ describe('emitClientSingleFile — pagination', () => { }); it('grouped argsStyle needs no wrapper — properties ride along on the destructure', () => { - const out = emit(PAGINATED, { pagination: config, argsStyle: 'grouped' }); + const out = emit(PAGINATED, { pagination, argsStyle: 'grouped' }); expect(out).toContain('export const { listOrders, getOrder } = client;'); expect(out).not.toContain('Object.assign'); }); @@ -480,7 +482,9 @@ describe('emitClientSingleFile — pagination', () => { it('embeds the paginate capability in inline mode only when a descriptor paginates', () => { // A security-free model, so paginate is the ONLY capability in the factory wiring. const model = modelWith([listOrders], { schemas: [SCHEMAS[0], ORDER_PAGE] }); - const paginated = emitClientSingleFile(model, { pagination: config }); + const paginated = emitClientSingleFile(model, { + pagination: resolveModelPagination(model, config), + }); expect(paginated).toContain('async function* pages'); expect(paginated).toContain( 'createClientCore(operations, config, { paginate: { pages, items, pagesByLink, itemsByLink } })' @@ -503,7 +507,7 @@ describe('emitClientSingleFile — pagination', () => { ], { schemas: [...SCHEMAS, ORDER_PAGE] } ); - expect(() => emitClientSingleFile(model)).toThrow( + expect(() => resolveModelPagination(model, undefined)).toThrow( 'Invalid pagination configuration:\n' + ' - Pagination for operation "listOrders" (x-redoclyPagination): ' + 'query parameter "after" is not declared on the operation (declared: cursor, limit)\n' + @@ -513,12 +517,12 @@ describe('emitClientSingleFile — pagination', () => { }); it('matches the golden output for a paginated package client', () => { - expect(emit(PAGINATED, { pagination: config })).toMatchSnapshot(); + expect(emit(PAGINATED, { pagination })).toMatchSnapshot(); }); it('matches the golden output for a result-mode paginated package client', () => { // Result mode: the Ops entry gains `page` (the raw page `.pages()` yields) next to // the envelope-wrapped `result`. - expect(emit(PAGINATED, { pagination: config, errorMode: 'result' })).toMatchSnapshot(); + expect(emit(PAGINATED, { pagination, errorMode: 'result' })).toMatchSnapshot(); }); }); diff --git a/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts b/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts index 2233a36ef8..bf48497c45 100644 --- a/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts +++ b/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts @@ -1,4 +1,4 @@ -import type { PaginationConfig } from '../pagination.js'; +import { resolveModelPagination, type PaginationConfig } from '../pagination.js'; import { renderTanstackModule } from '../tanstack-query.js'; import { apiModel, namedSchema, operation, param, SCALAR } from './fixtures.js'; @@ -12,13 +12,15 @@ function render( schemas?: NonNullable[0]>['schemas']; } = {} ) { - return renderTanstackModule( - apiModel({ - schemas: extra.schemas ?? [], - services: [{ name: 'Default', operations: ops.map(operation) }], - }), - { sdkModule: SDK, framework: extra.framework ?? 'react', pagination: extra.pagination } - ); + const model = apiModel({ + schemas: extra.schemas ?? [], + services: [{ name: 'Default', operations: ops.map(operation) }], + }); + return renderTanstackModule(model, { + sdkModule: SDK, + framework: extra.framework ?? 'react', + pagination: resolveModelPagination(model, extra.pagination), + }); } describe('renderTanstackModule', () => { @@ -442,16 +444,21 @@ describe('a pagination parameter whose name is not an identifier', () => { }; it('reads it with bracket access in both argument styles', () => { - const grouped = renderTanstackModule( - apiModel({ services: [{ name: 'Default', operations: [operation(spec)] }] }), - { sdkModule: SDK, framework: 'react', pagination } - ); + const model = apiModel({ services: [{ name: 'Default', operations: [operation(spec)] }] }); + const resolved = resolveModelPagination(model, pagination); + const grouped = renderTanstackModule(model, { + sdkModule: SDK, + framework: 'react', + pagination: resolved, + }); expect(grouped).toContain('initialPageParam: vars.query?.["after-cursor"]'); - const flat = renderTanstackModule( - apiModel({ services: [{ name: 'Default', operations: [operation(spec)] }] }), - { sdkModule: SDK, framework: 'react', pagination, argsStyle: 'flat' } - ); + const flat = renderTanstackModule(model, { + sdkModule: SDK, + framework: 'react', + pagination: resolved, + argsStyle: 'flat', + }); // `vars.["after-cursor"]` would not even parse. expect(flat).toContain('initialPageParam: vars["after-cursor"]'); expect(flat).not.toContain('vars.['); diff --git a/packages/client-generator/src/emitters/cli.ts b/packages/client-generator/src/emitters/cli.ts index dd34734e7f..a178254732 100644 --- a/packages/client-generator/src/emitters/cli.ts +++ b/packages/client-generator/src/emitters/cli.ts @@ -20,7 +20,7 @@ import { } from '../runtime/cli.js'; import { HEADER } from './emit-options.js'; import { embedCliRuntime } from './inline-runtime.js'; -import { resolveOperationPagination, type PaginationConfig } from './pagination.js'; +import type { ModelPagination } from './pagination.js'; import { flatInputShape } from './render-client.js'; import { isSseOp } from './sse.js'; @@ -97,7 +97,7 @@ function groupedInputFlag( /** Every operation as pure command data — the table `runCli` interprets. */ export function commandData( model: ApiModel, - emit: { pagination?: PaginationConfig; argsStyle?: 'grouped' | 'flat' } + emit: { pagination?: ModelPagination; argsStyle?: 'grouped' | 'flat' } ): CliCommand[] { const commands: CliCommand[] = []; for (const service of model.services) { @@ -124,9 +124,7 @@ export function commandData( ...(jsonBody === undefined && op.requestBody !== undefined ? { unsupportedBody: op.requestBody.contentType } : {}), - ...(resolveOperationPagination(op, model, emit.pagination).spec !== undefined - ? { paginated: true } - : {}), + ...(emit.pagination?.has(op.name) === true ? { paginated: true } : {}), ...groupedInputFlag(op, model, emit.argsStyle), ...(isSseOp(op) ? { sse: true } : {}), ...(isBlobOp(op) ? { blob: true } : {}), @@ -173,7 +171,7 @@ export type CliModuleOptions = { importExt: string; runtime: 'inline' | 'package'; zodSelected: boolean; - pagination?: PaginationConfig; + pagination?: ModelPagination; /** The sibling client's call shape, which the dispatcher builds its inputs for. */ argsStyle?: 'grouped' | 'flat'; }; diff --git a/packages/client-generator/src/emitters/client-assembly.ts b/packages/client-generator/src/emitters/client-assembly.ts index b47f7c9693..2d8bbeb258 100644 --- a/packages/client-generator/src/emitters/client-assembly.ts +++ b/packages/client-generator/src/emitters/client-assembly.ts @@ -21,7 +21,6 @@ import { codeString } from './identifier.js'; import { assembleInlineRuntime } from './inline-runtime.js'; import { isTypedMultipart } from './operation-types.js'; import type { EmitContext } from './operations.js'; -import { resolveModelPagination } from './pagination.js'; import { collectEntrySchemaRefs, renderAliases, renderOpsType } from './render-client.js'; import { isSseOp } from './sse.js'; import { renderTypeAliases } from './ts-type.js'; @@ -59,7 +58,7 @@ function emitClient( const idents = packageIdents(model); // Resolved (and VERIFIED) up front: an explicit rule that doesn't fit throws here, // before any statement is built — one aggregated error for the whole model. - const pagination = resolveModelPagination(model, options.pagination); + const pagination = options.pagination ?? new Map(); const ctx: EmitContext = { argsStyle: options.argsStyle ?? 'grouped', errorMode: options.errorMode ?? 'throw', diff --git a/packages/client-generator/src/emitters/emit-options.ts b/packages/client-generator/src/emitters/emit-options.ts index de87942898..9eb9560a7a 100644 --- a/packages/client-generator/src/emitters/emit-options.ts +++ b/packages/client-generator/src/emitters/emit-options.ts @@ -1,7 +1,7 @@ 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 type { ModelPagination } from './pagination.js'; import { splitLines } from './support.js'; import type { DateType } from './types.js'; @@ -64,11 +64,11 @@ export type EmitOptions = { */ goPackage?: string; /** - * Auto-pagination rules (a convention rule + per-operation overrides + `exclude`), + * Auto-pagination RESOLVED by the pipeline (fit-verified, one answer per run), * resolved together with each operation's `x-redoclyPagination` extension. Verified * statically: an explicit rule that doesn't fit its operation fails generation. */ - pagination?: PaginationConfig; + pagination?: ModelPagination; /** * Also write the reference documentation for what each selected generator emits: one * Markdown page per generator that implements the `docs` hook. One switch for the whole diff --git a/packages/client-generator/src/emitters/tanstack-query.ts b/packages/client-generator/src/emitters/tanstack-query.ts index 4e0a43fc06..c9b50a08b9 100644 --- a/packages/client-generator/src/emitters/tanstack-query.ts +++ b/packages/client-generator/src/emitters/tanstack-query.ts @@ -17,12 +17,7 @@ import type { ApiModel, OperationModel } from '../intermediate-representation/model.js'; import type { PaginationSpec } from '../runtime/types.js'; import { codeString, isSafeIdentifier, safeIdent } from './identifier.js'; -import { - type ModelPagination, - type PaginationConfig, - resolveModelPagination, - resolveSchemaPointer, -} from './pagination.js'; +import { type ModelPagination, resolveSchemaPointer } from './pagination.js'; import { hasInputs, isQuery, variablesName, wrappableOperations } from './wrapper-support.js'; export type TanstackOptions = { @@ -30,8 +25,8 @@ export type TanstackOptions = { sdkModule: string; /** TanStack adapter to import the option helpers from (`@tanstack/${framework}-query`). */ framework: 'react' | 'vue' | 'svelte' | 'solid'; - /** Auto-pagination rules — paginated query ops gain `InfiniteOptions`. */ - pagination?: PaginationConfig; + /** The run's RESOLVED pagination — paginated query ops gain `InfiniteOptions`. */ + pagination?: ModelPagination; /** Leading element for every query/mutation key — namespaces the cache when several * generated APIs share one QueryClient (operationIds may collide across APIs). */ queryKeyPrefix?: string; @@ -43,7 +38,7 @@ export type TanstackOptions = { export function renderTanstackModule(model: ApiModel, opts: TanstackOptions): string { const ops = wrappableOperations(model, 'tanstack-query'); if (ops.length === 0) return ''; - const pagination = resolveModelPagination(model, opts.pagination); + const pagination = opts.pagination ?? new Map(); const source = [ importHeader(ops, opts, pagination), ...ops.filter(isQuery).map((op) => queryKeySource(op, opts.queryKeyPrefix)), diff --git a/packages/client-generator/src/generators/__tests__/go.test.ts b/packages/client-generator/src/generators/__tests__/go.test.ts index c11a0cb57b..99224991e8 100644 --- a/packages/client-generator/src/generators/__tests__/go.test.ts +++ b/packages/client-generator/src/generators/__tests__/go.test.ts @@ -3,8 +3,14 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { resolveModelPagination } from '../../emitters/pagination.js'; import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; -import { goGenerator, goSample, renderGoModels } from '../go/index.js'; +import { goGenerator as goGeneratorEntry, goSample, renderGoModels } from '../go/index.js'; + +// The pipeline resolves pagination once and hands generators the map; these direct +// calls mirror that step. +const goGenerator = (input: Omit[0], 'pagination'>) => + goGeneratorEntry({ ...input, pagination: resolveModelPagination(input.model, undefined) }); const hasGo = spawnSync('go', ['version']).status === 0; @@ -349,6 +355,7 @@ const CAFE: ApiModel = { schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, required: true, }, + { name: 'next', schema: STRING, required: false }, ], }, }, diff --git a/packages/client-generator/src/generators/__tests__/php.test.ts b/packages/client-generator/src/generators/__tests__/php.test.ts index ebc202f9e4..464c1f8021 100644 --- a/packages/client-generator/src/generators/__tests__/php.test.ts +++ b/packages/client-generator/src/generators/__tests__/php.test.ts @@ -3,8 +3,19 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { resolveModelPagination } from '../../emitters/pagination.js'; import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; -import { phpGenerator, phpSample, phpType, renderPhpModels } from '../php/index.js'; +import { + phpGenerator as phpGeneratorEntry, + phpSample, + phpType, + renderPhpModels, +} from '../php/index.js'; + +// The pipeline resolves pagination once and hands generators the map; these direct +// calls mirror that step. +const phpGenerator = (input: Omit[0], 'pagination'>) => + phpGeneratorEntry({ ...input, pagination: resolveModelPagination(input.model, undefined) }); const hasPhp = spawnSync('php', ['--version']).status === 0; @@ -620,7 +631,8 @@ describe('phpGenerator (full client assembly)', () => { headerParams: [], cookieParams: [], security: [], - paginationExtension: { style: 'cursor', cursorParam: 'cursor', items: '' }, + paginationExtension: { style: 'link', items: '' }, + successResponseHeaders: [{ name: 'link', schema: STRING }], successResponses: [ { status: '200', diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts index fd8e6bfc55..a71360ac0c 100644 --- a/packages/client-generator/src/generators/__tests__/python.test.ts +++ b/packages/client-generator/src/generators/__tests__/python.test.ts @@ -3,8 +3,14 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { resolveModelPagination } from '../../emitters/pagination.js'; import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; -import { pythonGenerator, renderPythonModels } from '../python/index.js'; +import { pythonGenerator as pythonGeneratorEntry, renderPythonModels } from '../python/index.js'; + +// The pipeline resolves pagination once and hands generators the map; these direct +// calls mirror that step. +const pythonGenerator = (input: Omit[0], 'pagination'>) => + pythonGeneratorEntry({ ...input, pagination: resolveModelPagination(input.model, undefined) }); const hasPython = spawnSync('python3', ['--version']).status === 0; const hasHttpx = hasPython && spawnSync('python3', ['-c', 'import httpx']).status === 0; @@ -400,6 +406,7 @@ const CAFE: ApiModel = { schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, required: true, }, + { name: 'next', schema: STRING, required: false }, ], }, }, @@ -551,7 +558,17 @@ describe('pythonGenerator parity features', () => { { status: '200', contentType: 'application/json', - schema: { kind: 'object', properties: [] }, + schema: { + kind: 'object', + properties: [ + { + name: 'items', + schema: { kind: 'array', items: { kind: 'object', properties: [] } }, + required: true, + }, + { name: 'next', schema: STRING, required: false }, + ], + }, }, ], errorResponses: [], @@ -641,7 +658,17 @@ describe('pythonGenerator parity features', () => { { status: '200', contentType: 'application/json', - schema: { kind: 'object', properties: [] }, + schema: { + kind: 'object', + properties: [ + { + name: 'items', + schema: { kind: 'array', items: { kind: 'object', properties: [] } }, + required: true, + }, + { name: 'next', schema: STRING, required: false }, + ], + }, }, ], errorResponses: [], diff --git a/packages/client-generator/src/generators/cli/index.ts b/packages/client-generator/src/generators/cli/index.ts index 2f63003d0a..4fda52b0c1 100644 --- a/packages/client-generator/src/generators/cli/index.ts +++ b/packages/client-generator/src/generators/cli/index.ts @@ -13,14 +13,14 @@ import type { CodeSample, Generator, SampleContext } from '../types.js'; * bodies, env auth, `--page-all`, SSE/blob output, a documented exit-code * contract). Requires `typescript` (throw mode); wires zod validation when co-selected. */ -export const cliGenerator: Generator = ({ model, outputPath, emit, selected }) => { +export const cliGenerator: Generator = ({ model, outputPath, emit, selected, pagination }) => { 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, + pagination, argsStyle: emit.argsStyle ?? 'grouped', }); return [{ path: join(dir, `${stem}.cli.ts`), content }]; @@ -32,9 +32,9 @@ export const cliGenerator: Generator = ({ model, outputPath, emit, selected }) = * It renders from `commandData` — the same table `runCli` dispatches on — so the page * cannot describe a tool other than the one beside it. */ -export const cliDocs: Generator = ({ model, outputPath, emit }) => { +export const cliDocs: Generator = ({ model, outputPath, emit, pagination }) => { const { dir, stem } = anchor(outputPath); - const content = renderCliDocs(commandData(model, { pagination: emit.pagination }), { + const content = renderCliDocs(commandData(model, { pagination }), { title: `${model.title} command-line reference`, frontmatter: emit.docsFrontmatter === true, name: stem, @@ -45,7 +45,7 @@ export const cliDocs: Generator = ({ model, outputPath, emit }) => { /** One shell invocation per operation — feeds `x-codeSamples` for docs. */ export function cliSample(op: OperationModel, ctx: SampleContext): CodeSample | undefined { - const command = commandData(ctx.model, { pagination: ctx.emit.pagination }).find( + const command = commandData(ctx.model, { pagination: ctx.pagination }).find( (candidate) => candidate.name === op.name ); if (command === undefined) return undefined; diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index 77ed56320f..2491f7cbc0 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -14,7 +14,6 @@ import { uniqueIdentifiers, isNullable, NotSupportedError, - paginationRuleFor, renderReferencePage, RESERVED_WORDS, unwrapNullable, @@ -827,14 +826,15 @@ function writeGoServers(printer: GoPrinter, model: ApiModel): void { } /** The whole generated file: models + embedded runtime + operations table + Client. */ -export const goGenerator: Generator = ({ model, outputPath, emit }) => { +export const goGenerator: Generator = ({ model, outputPath, emit, pagination }) => { const printer = new GoPrinter(); const dateType = emit.dateType ?? 'string'; const packageName = goPackageName(emit.goPackage); + // Pagination arrives RESOLVED from the pipeline — one fit-verified answer per run. const paginationRules = new Map(); for (const { op, ident } of goOperationIdents(model)) { - const rule = paginationRuleFor(op, emit.pagination as Record | undefined); - if (rule !== undefined) paginationRules.set(ident, rule); + const spec = pagination?.get(op.name)?.spec; + if (spec !== undefined) paginationRules.set(ident, spec); } printer.line( `// Code generated by @redocly/client-generator (go) from "${model.title}" ${model.version}. DO NOT EDIT.` @@ -1025,7 +1025,7 @@ export function goSample(op: OperationModel, ctx: SampleContext): CodeSample { * from `goSample` — this generator's own hook — so the page can only ever show the syntax * of the SDK beside it, and ejecting this generator takes the page with it. */ -export const goDocs: Generator = ({ model, outputPath, emit }) => [ +export const goDocs: Generator = ({ model, outputPath, emit, pagination }) => [ { path: outputPath.replace(/\.[^.\\/]+$/, '.go.md'), content: renderReferencePage(model, { @@ -1038,7 +1038,7 @@ export const goDocs: Generator = ({ model, outputPath, emit }) => [ requires: 'The SDK needs the standard library only.', }, sample: (op) => goSample(op, { model, emit, outputPath }), - pagination: emit.pagination, + paginated: new Set(pagination?.keys() ?? []), }), }, ]; diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index 3911934d7e..22f0f0146c 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -13,7 +13,6 @@ import { identifierFor, uniqueIdentifiers, isNullable, - paginationRuleFor, renderReferencePage, RESERVED_WORDS, unwrapNullable, @@ -882,7 +881,7 @@ function stripPhpHeader(source: string): string { } /** The whole generated file: namespace + models + embedded runtime + operations + Client. */ -export const phpGenerator: Generator = ({ model, outputPath, emit }) => { +export const phpGenerator: Generator = ({ model, outputPath, emit, pagination }) => { const printer = new PhpPrinter(); const dateType = emit.dateType ?? 'string'; const namespace = identifierFor(model.title, { style: 'pascal', reserved: PHP }); @@ -907,10 +906,11 @@ export const phpGenerator: Generator = ({ model, outputPath, emit }) => { const operations = model.services.flatMap((service) => service.operations); const idents = methodIdents(model); + // Pagination arrives RESOLVED from the pipeline — one fit-verified answer per run. const paginationRules = new Map(); for (const op of operations) { - const rule = paginationRuleFor(op, emit.pagination as Record | undefined); - if (rule !== undefined) paginationRules.set(op.name, rule); + const spec = pagination?.get(op.name)?.spec; + if (spec !== undefined) paginationRules.set(op.name, spec); } printer.block( @@ -1015,7 +1015,7 @@ export function phpSample(op: OperationModel, ctx: SampleContext): CodeSample { * from `phpSample` — this generator's own hook — so the page can only ever show the syntax * of the SDK beside it, and ejecting this generator takes the page with it. */ -export const phpDocs: Generator = ({ model, outputPath, emit }) => [ +export const phpDocs: Generator = ({ model, outputPath, emit, pagination }) => [ { path: outputPath.replace(/\.[^.\\/]+$/, '.php.md'), content: renderReferencePage(model, { @@ -1028,7 +1028,7 @@ export const phpDocs: Generator = ({ model, outputPath, emit }) => [ requires: 'The SDK needs the curl extension.', }, sample: (op) => phpSample(op, { model, emit, outputPath }), - pagination: emit.pagination, + paginated: new Set(pagination?.keys() ?? []), }), }, ]; diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index 65d33d477b..8b0b145e69 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -4,7 +4,7 @@ // A guard test pins that this module never imports the TS emitter toolkit. import { - paginationRuleFor, + type NeutralPaginationRule, renderReferencePage, discriminatorCases, enumValues, @@ -385,13 +385,11 @@ function operationIdents(model: ApiModel): Array<{ op: OperationModel; ident: st return operations.map((op, index) => ({ op, ident: idents[index] })); } -/** The neutral pagination rule mapped to the snake_case spec dict the embedded +/** The resolved pagination rule mapped to the snake_case spec dict the embedded * Python runtime consumes. */ function paginationSpec( - op: OperationModel, - emit: { pagination?: Record } + rule: NeutralPaginationRule | undefined ): Record | undefined { - const rule = paginationRuleFor(op, emit.pagination); if (rule === undefined) return undefined; return { style: rule.style, @@ -732,7 +730,7 @@ function pythonModulePath(outputPath: string): string { } /** The whole generated file: header, models, embedded runtime, descriptors, clients. */ -export const pythonGenerator: Generator = ({ model, outputPath, emit, options }) => { +export const pythonGenerator: Generator = ({ model, outputPath, emit, options, pagination }) => { const errorMode = emit.errorMode ?? 'throw'; const dateType = emit.dateType ?? 'string'; const models = (options?.models as PythonModels | undefined) ?? 'dataclass'; @@ -788,10 +786,7 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit, options }) // The wire-shape descriptor table the runtime routes by. const paginationSpecs = new Map | undefined>(); for (const { op, ident } of operationIdents(model)) { - paginationSpecs.set( - ident, - paginationSpec(op, emit as { pagination?: Record }) - ); + paginationSpecs.set(ident, paginationSpec(pagination?.get(op.name)?.spec)); } printer.line('_OPERATIONS = {'); printer.indent(() => { @@ -859,7 +854,7 @@ export function pythonSample(op: OperationModel, ctx: SampleContext): CodeSample * from `pythonSample` — this generator's own hook — so the page can only ever show the syntax * of the SDK beside it, and ejecting this generator takes the page with it. */ -export const pythonDocs: Generator = ({ model, outputPath, emit }) => [ +export const pythonDocs: Generator = ({ model, outputPath, emit, pagination }) => [ { path: outputPath.replace(/\.[^.\\/]+$/, '.python.md'), content: renderReferencePage(model, { @@ -872,7 +867,7 @@ export const pythonDocs: Generator = ({ model, outputPath, emit }) => [ requires: 'The SDK needs `httpx`.', }, sample: (op) => pythonSample(op, { model, emit, outputPath }), - pagination: emit.pagination, + paginated: new Set(pagination?.keys() ?? []), }), }, ]; diff --git a/packages/client-generator/src/generators/tanstack-query/index.ts b/packages/client-generator/src/generators/tanstack-query/index.ts index 9a455da762..115a084877 100644 --- a/packages/client-generator/src/generators/tanstack-query/index.ts +++ b/packages/client-generator/src/generators/tanstack-query/index.ts @@ -21,13 +21,13 @@ import type { Generator } from '../types.js'; * no operations. */ export function tanstackQueryGenerator(framework: 'react' | 'vue' | 'svelte' | 'solid'): Generator { - return ({ model, outputPath, emit }) => { + return ({ model, outputPath, emit, pagination }) => { const { dir, stem } = anchor(outputPath); const content = renderTanstackModule(model, { argsStyle: emit.argsStyle ?? 'grouped', sdkModule: `./${stem}.${emit.importExt ?? 'js'}`, framework, - pagination: emit.pagination, + pagination, queryKeyPrefix: emit.queryKeyPrefix, }); if (content === '') return []; diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index 331d26d34a..58a7423a56 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -1,6 +1,7 @@ -// packages/client-generator/src/generators/types.ts import type { EmitOptions } from '../emitters/emit-options.js'; import type { ErrorMode } from '../emitters/operations.js'; +// packages/client-generator/src/generators/types.ts +import type { ModelPagination } from '../emitters/pagination.js'; import type { DateType } from '../emitters/types.js'; import type { ApiModel, OperationModel } from '../intermediate-representation/model.js'; @@ -57,6 +58,13 @@ export type GeneratorInput = { model: ApiModel; /** The `--output` anchor path. */ outputPath: string; + /** + * Pagination resolved ONCE by the pipeline — per-op config > `x-redoclyPagination` > + * convention, fit-verified, pointers resolved — keyed by operation name. Generators + * read this instead of re-resolving, so two of them cannot disagree about whether an + * operation paginates. + */ + pagination?: ModelPagination; /** File partitioning the generator should honor. */ outputMode: OutputMode; /** Emit options — serverUrl, runtime, and the generator knobs (dateType, mockData, …); see `EmitOptions`. */ @@ -87,7 +95,13 @@ export type CodeSample = { lang: string; label?: string; source: string }; * derives that name from the anchor its own way (`openapi.client.ts` becomes * `openapi_client.py`), so a hardcoded module name is wrong for most stems. */ -export type SampleContext = { model: ApiModel; emit: EmitOptions; outputPath: string }; +export type SampleContext = { + model: ApiModel; + emit: EmitOptions; + outputPath: string; + /** The run's resolved pagination (see `GeneratorInput.pagination`). */ + pagination?: ModelPagination; +}; /** * A generator plus its declared compatibility contract. `validateGenerators` diff --git a/packages/client-generator/src/generators/typescript/index.ts b/packages/client-generator/src/generators/typescript/index.ts index e690ee9ac6..65a24dcff4 100644 --- a/packages/client-generator/src/generators/typescript/index.ts +++ b/packages/client-generator/src/generators/typescript/index.ts @@ -35,7 +35,7 @@ export const typescriptGenerator: Generator = ({ model, outputPath, outputMode, * `typescriptSample` below, so the page shows the calling convention this run generated — * `argsStyle` included. */ -export const typescriptDocs: Generator = ({ model, outputPath, emit }) => [ +export const typescriptDocs: Generator = ({ model, outputPath, emit, pagination }) => [ { path: outputPath.replace(/\.[^.\\/]+$/, '.typescript.md'), content: renderReferencePage(model, { @@ -48,7 +48,7 @@ export const typescriptDocs: Generator = ({ model, outputPath, emit }) => [ requires: 'The client has no dependencies.', }, sample: (op) => typescriptSample(op, { model, emit, outputPath }), - pagination: emit.pagination, + paginated: new Set(pagination?.keys() ?? []), }), }, ]; diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index 6c5bd083f9..9c48a2e1f8 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -11,6 +11,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, resolve, sep } from 'node:path'; import type { EmitOptions } from './emitters/emit-options.js'; +import { resolveModelPagination, type ModelPagination } from './emitters/pagination.js'; import { NotSupportedError } from './errors.js'; import { validateSelection } from './generators/meta.js'; import { resolveGeneratorOptions } from './generators/options.js'; @@ -38,6 +39,8 @@ export function runGenerators( outputPath: string; outputMode: OutputMode; emit: EmitOptions; + /** Pagination resolved once for the whole run (see `GeneratorInput.pagination`). */ + pagination?: ModelPagination; generators: string[]; registry: Map; /** Per-generator options, already validated (see `resolveGeneratorOptions`). */ @@ -57,6 +60,7 @@ export function runGenerators( outputPath: options.outputPath, outputMode: options.outputMode, emit: options.emit, + pagination: options.pagination, selected: options.generators, options: options.generatorOptions?.get(name) ?? {}, }; @@ -154,12 +158,13 @@ function codeSamplesOverlay( emit: EmitOptions, selected: string[], registry: Map, - outputPath: string + outputPath: string, + pagination?: ModelPagination ): string | undefined { const actions = []; for (const op of allOperations(model.services)) { const samples = selected - .map((name) => registry.get(name)?.sample?.(op, { model, emit, outputPath })) + .map((name) => registry.get(name)?.sample?.(op, { model, emit, outputPath, pagination })) .filter((sample): sample is CodeSample => sample !== undefined); if (samples.length > 0) { actions.push({ @@ -231,6 +236,9 @@ export async function generateClient( configDir: options.configDir, }); + // ONE pagination resolution for the run: fit-verified, pointers resolved, errors + // reported before any generator writes a file. + const pagination = resolveModelPagination(model, options.pagination); const emit: EmitOptions = { serverUrl: options.serverUrl, argsStyle: options.argsStyle, @@ -243,7 +251,7 @@ export async function generateClient( runtime: options.runtime, importExt: options.importExt, goPackage: options.goPackage, - pagination: options.pagination, + pagination, docs: options.docs, docsFrontmatter: options.docsFrontmatter, }; @@ -256,13 +264,14 @@ export async function generateClient( outputPath, outputMode: options.outputMode ?? 'single', emit, + pagination, generators: selected, generatorOptions, registry, }); if (options.codeSamples === true) { - const overlay = codeSamplesOverlay(model, emit, selected, registry, outputPath); + const overlay = codeSamplesOverlay(model, emit, selected, registry, outputPath, pagination); if (overlay !== undefined) { files.push({ path: outputPath.replace(/\.[^.]+$/, '.code-samples.yaml'), content: overlay }); } From 43074a743df2acff683f920ed2ec738e4f70cc6d Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 21:20:33 +0300 Subject: [PATCH 16/35] refactor: compute SSE facts once in the IR builder as op.sse and drop the emitters/sse helpers --- .../src/emitters/__tests__/cli.test.ts | 1 + .../src/emitters/__tests__/fixtures.ts | 7 +- .../src/emitters/__tests__/sse.test.ts | 101 ------------------ packages/client-generator/src/emitters/cli.ts | 3 +- .../src/emitters/client-assembly.ts | 5 +- .../src/emitters/descriptor.ts | 5 +- .../src/emitters/pagination.ts | 3 +- .../src/emitters/render-client.ts | 7 +- packages/client-generator/src/emitters/sse.ts | 43 -------- .../src/emitters/wrapper-support.ts | 7 +- packages/client-generator/src/emitters/zod.ts | 3 +- .../__tests__/build.test.ts | 94 +++++++++++++++- .../src/intermediate-representation/build.ts | 32 ++++++ .../src/intermediate-representation/model.ts | 14 +++ 14 files changed, 158 insertions(+), 167 deletions(-) delete mode 100644 packages/client-generator/src/emitters/__tests__/sse.test.ts delete mode 100644 packages/client-generator/src/emitters/sse.ts diff --git a/packages/client-generator/src/emitters/__tests__/cli.test.ts b/packages/client-generator/src/emitters/__tests__/cli.test.ts index 817dec8fa3..76bfb7ccba 100644 --- a/packages/client-generator/src/emitters/__tests__/cli.test.ts +++ b/packages/client-generator/src/emitters/__tests__/cli.test.ts @@ -120,6 +120,7 @@ const MODEL: ApiModel = { schema: { kind: 'object', properties: [] }, }, ], + sse: { eventSchema: { kind: 'object', properties: [] }, dataKind: 'json' }, errorResponses: [], }, { diff --git a/packages/client-generator/src/emitters/__tests__/fixtures.ts b/packages/client-generator/src/emitters/__tests__/fixtures.ts index 6e57e09f58..f413e25179 100644 --- a/packages/client-generator/src/emitters/__tests__/fixtures.ts +++ b/packages/client-generator/src/emitters/__tests__/fixtures.ts @@ -1,3 +1,4 @@ +import { sseFromResponses } from '../../intermediate-representation/build.js'; import type { ApiModel, NamedSchemaModel, @@ -34,7 +35,7 @@ export function namedSchema( /** A minimal `GET /p` operation; spread `overrides` to add params, a body, responses, etc. */ export function operation(overrides: Partial = {}): OperationModel { - return { + const built: OperationModel = { name: 'op', method: 'get', path: '/p', @@ -48,6 +49,10 @@ export function operation(overrides: Partial = {}): OperationMod tags: [], ...overrides, }; + // The IR builder stamps `sse` on every real operation; mirror it here so fixtures + // carry the same facts the emitters read in production. + const sse = overrides.sse ?? sseFromResponses(built.successResponses); + return { ...built, ...(sse === undefined ? {} : { sse }) }; } export function param( diff --git a/packages/client-generator/src/emitters/__tests__/sse.test.ts b/packages/client-generator/src/emitters/__tests__/sse.test.ts deleted file mode 100644 index dacea5a0cf..0000000000 --- a/packages/client-generator/src/emitters/__tests__/sse.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { ResponseBodyModel, SchemaModel } from '../../intermediate-representation/model.js'; -import { eventSchema, isSseOp, sseDataKind } from '../sse.js'; -import { operation } from './fixtures.js'; - -/** An operation whose success response streams `text/event-stream`. */ -function sseOp(response: Partial, name = 'streamMessages') { - return operation({ - name, - successResponses: [ - { contentType: 'text/event-stream', schema: { kind: 'unknown' }, ...response, status: 200 }, - ], - }); -} - -describe('isSseOp', () => { - it('is true for a success response with the text/event-stream content type', () => { - expect(isSseOp(sseOp({}))).toBe(true); - }); - - it('matches with parameters and is case-insensitive', () => { - expect(isSseOp(sseOp({ contentType: 'text/event-stream; charset=utf-8' }))).toBe(true); - expect(isSseOp(sseOp({ contentType: 'Text/Event-Stream' }))).toBe(true); - }); - - it('is false for a plain JSON operation', () => { - expect( - isSseOp( - operation({ - successResponses: [ - { contentType: 'application/json', schema: { kind: 'ref', name: 'Pet' }, status: 200 }, - ], - }) - ) - ).toBe(false); - }); - - it('is false for an operation with no responses', () => { - expect(isSseOp(operation({}))).toBe(false); - }); -}); - -describe('eventSchema (drives the streamed payload type)', () => { - it('uses the per-item schema when present', () => { - expect(eventSchema(sseOp({ itemSchema: { kind: 'ref', name: 'Message' } }))).toEqual({ - kind: 'ref', - name: 'Message', - }); - }); - - it('falls back to the response schema when it is meaningful', () => { - expect(eventSchema(sseOp({ schema: { kind: 'ref', name: 'Token' } }))).toEqual({ - kind: 'ref', - name: 'Token', - }); - }); - - it('ignores a typeless `itemSchema` and falls back to the response schema', () => { - expect( - eventSchema( - sseOp({ itemSchema: { kind: 'unknown' }, schema: { kind: 'ref', name: 'Token' } }) - ) - ).toEqual({ kind: 'ref', name: 'Token' }); - }); - - it('is undefined when no schema is declared (payload types as `string`)', () => { - expect(eventSchema(sseOp({}))).toBeUndefined(); - expect(eventSchema(operation({}))).toBeUndefined(); - }); -}); - -describe('sseDataKind', () => { - it("is 'json' for object/ref/array/record/union/intersection event types", () => { - const json: SchemaModel[] = [ - { kind: 'object', properties: [] }, - { kind: 'ref', name: 'Message' }, - { kind: 'array', items: { kind: 'scalar', scalar: 'string' } }, - { kind: 'record', value: { kind: 'scalar', scalar: 'string' } }, - { kind: 'union', members: [{ kind: 'ref', name: 'A' }] }, - { kind: 'intersection', members: [{ kind: 'ref', name: 'A' }] }, - ]; - for (const itemSchema of json) expect(sseDataKind(sseOp({ itemSchema }))).toBe('json'); - }); - - it("is 'text' for the string fallback (no schema)", () => { - expect(sseDataKind(sseOp({}))).toBe('text'); - }); - - it("is 'text' for a typeless `itemSchema` (no meaningful schema)", () => { - expect(sseDataKind(sseOp({ itemSchema: { kind: 'unknown' } }))).toBe('text'); - }); - - it("is 'text' for scalar/literal/enum/null event types", () => { - const text: SchemaModel[] = [ - { kind: 'scalar', scalar: 'string' }, - { kind: 'literal', value: 'x' }, - { kind: 'enum', values: ['a'], scalar: 'string' }, - { kind: 'null' }, - ]; - for (const itemSchema of text) expect(sseDataKind(sseOp({ itemSchema }))).toBe('text'); - }); -}); diff --git a/packages/client-generator/src/emitters/cli.ts b/packages/client-generator/src/emitters/cli.ts index a178254732..3f1c61908c 100644 --- a/packages/client-generator/src/emitters/cli.ts +++ b/packages/client-generator/src/emitters/cli.ts @@ -22,7 +22,6 @@ import { HEADER } from './emit-options.js'; import { embedCliRuntime } from './inline-runtime.js'; import type { ModelPagination } 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, '-'); @@ -126,7 +125,7 @@ export function commandData( : {}), ...(emit.pagination?.has(op.name) === true ? { paginated: true } : {}), ...groupedInputFlag(op, model, emit.argsStyle), - ...(isSseOp(op) ? { sse: true } : {}), + ...(op.sse !== undefined ? { sse: true } : {}), ...(isBlobOp(op) ? { blob: true } : {}), ...(jsonBody !== undefined || responseSchema !== undefined ? { diff --git a/packages/client-generator/src/emitters/client-assembly.ts b/packages/client-generator/src/emitters/client-assembly.ts index 2d8bbeb258..22662c5811 100644 --- a/packages/client-generator/src/emitters/client-assembly.ts +++ b/packages/client-generator/src/emitters/client-assembly.ts @@ -22,7 +22,6 @@ import { assembleInlineRuntime } from './inline-runtime.js'; import { isTypedMultipart } from './operation-types.js'; import type { EmitContext } from './operations.js'; import { collectEntrySchemaRefs, renderAliases, renderOpsType } from './render-client.js'; -import { isSseOp } from './sse.js'; import { renderTypeAliases } from './ts-type.js'; import { renderTypeGuards } from './type-guards.js'; @@ -67,8 +66,8 @@ function emitClient( schemas: model.schemas, pagination, }; - const hasSse = ops.some(isSseOp); - const hasRegular = ops.some((op) => !isSseOp(op)); + const hasSse = ops.some((op) => op.sse !== undefined); + const hasRegular = ops.some((op) => op.sse === undefined); const wiring = ops.length > 0 diff --git a/packages/client-generator/src/emitters/descriptor.ts b/packages/client-generator/src/emitters/descriptor.ts index 07f4cc446b..d26b546a4c 100644 --- a/packages/client-generator/src/emitters/descriptor.ts +++ b/packages/client-generator/src/emitters/descriptor.ts @@ -18,7 +18,6 @@ import type { ModelPagination } from './pagination.js'; import { flatInputShape, responseText } from './render-client.js'; import { WIRING_NAMES } from './reserved-names.js'; import { responseHeaderSpecs } from './response-headers.js'; -import { isSseOp, sseDataKind } from './sse.js'; import { codeLiteral } from './ts-literal.js'; import { tsJsdoc } from './ts-type.js'; import type { DateType } from './types.js'; @@ -55,7 +54,7 @@ function descriptorValue( }) ); const security = securityRequirements(op, { securitySchemes: schemes }); - const sse = isSseOp(op); + const sse = op.sse !== undefined; const responseKind = sse ? 'sse' : responseText(op.successResponses, dateType).kind; const responseHeaders = responseHeaderSpecs(op.successResponseHeaders, schemas); return { @@ -77,7 +76,7 @@ function descriptorValue( } : {}), ...(responseKind !== 'json' ? { responseKind } : {}), - ...(sse ? { sseDataKind: sseDataKind(op) } : {}), + ...(op.sse === undefined ? {} : { sseDataKind: op.sse.dataKind }), ...(security.length > 0 ? { security } : {}), ...(responseHeaders === undefined ? {} : { responseHeaders }), // The resolved spec is already normalized with stable key order (see pagination.ts). diff --git a/packages/client-generator/src/emitters/pagination.ts b/packages/client-generator/src/emitters/pagination.ts index 6d3e96c798..655cc6ce62 100644 --- a/packages/client-generator/src/emitters/pagination.ts +++ b/packages/client-generator/src/emitters/pagination.ts @@ -16,7 +16,6 @@ import { type SchemaModel, } from '../intermediate-representation/model.js'; import type { PaginationSpec } from '../runtime/types.js'; -import { isSseOp } from './sse.js'; /** The pagination styles the generated runtime can drive. */ export type PaginationStyle = 'cursor' | 'offset' | 'page' | 'link'; @@ -140,7 +139,7 @@ function applyRule( const misfit = (problem: string): ResolvedPagination => explicit ? { error: `${label}: ${problem}` } : {}; - if (isSseOp(op)) return misfit('the operation is a Server-Sent Events stream'); + if (op.sse !== undefined) return misfit('the operation is a Server-Sent Events stream'); if (valid.style !== 'link') { const paramField = valid.style === 'cursor' ? 'cursorParam' : 'offsetParam'; const param = valid.style === 'cursor' ? valid.cursorParam! : valid.offsetParam!; diff --git a/packages/client-generator/src/emitters/render-client.ts b/packages/client-generator/src/emitters/render-client.ts index aeddc854c6..212828bb3d 100644 --- a/packages/client-generator/src/emitters/render-client.ts +++ b/packages/client-generator/src/emitters/render-client.ts @@ -17,7 +17,6 @@ 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'; @@ -102,7 +101,7 @@ export function errorTypeTexts( /** The TS type of a streamed event payload (`string` when no schema is declared). */ function sseEventText(op: OperationModel, dateType: DateType, indent = ''): string { - const schema = eventSchema(op); + const schema = op.sse?.eventSchema; return schema ? tsType(schema, dateType, indent) : 'string'; } @@ -351,7 +350,7 @@ export function renderOpsType( const name = pascalCase(op.name); const inner = INDENT + INDENT; const args = variablesTypeText(op, name, ctx, inner); - const sse = isSseOp(op); + const sse = op.sse !== undefined; const result = sse ? sseEventText(op, ctx.dateType, inner) : ctx.errorMode === 'result' @@ -394,7 +393,7 @@ export function renderOpsType( export function renderAliases(op: OperationModel, ctx: EmitContext): string { const { dateType, schemaNames } = ctx; const name = pascalCase(op.name); - const sse = isSseOp(op); + const sse = op.sse !== undefined; const { hasInputs } = operationSignature(op); const blocks: string[] = []; diff --git a/packages/client-generator/src/emitters/sse.ts b/packages/client-generator/src/emitters/sse.ts deleted file mode 100644 index a91d1bbc1e..0000000000 --- a/packages/client-generator/src/emitters/sse.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { - OperationModel, - ResponseBodyModel, - SchemaModel, -} from '../intermediate-representation/model.js'; - -/** The media type that marks an operation as a Server-Sent Events stream. */ -const SSE_CONTENT_TYPE = 'text/event-stream'; - -/** The event-stream success response of an operation, if it declares one. */ -function sseResponse(op: OperationModel): ResponseBodyModel | undefined { - return op.successResponses.find( - (r) => r.contentType.split(';')[0].trim().toLowerCase() === SSE_CONTENT_TYPE - ); -} - -/** Whether an operation streams Server-Sent Events. */ -export function isSseOp(op: OperationModel): boolean { - return sseResponse(op) !== undefined; -} - -/** The per-event schema: `itemSchema` → the response `schema` → undefined (typeless slots skipped). */ -export function eventSchema(op: OperationModel): SchemaModel | undefined { - const r = sseResponse(op); - if (!r) return undefined; - if (r.itemSchema && r.itemSchema.kind !== 'unknown') return r.itemSchema; - if (r.schema.kind !== 'unknown') return r.schema; - return undefined; -} - -/** Whether the streamed `data:` payload should be `JSON.parse`d (`'json'`) or passed raw (`'text'`). */ -export function sseDataKind(op: OperationModel): 'json' | 'text' { - const schema = eventSchema(op); - if (!schema) return 'text'; - return schema.kind === 'object' || - schema.kind === 'ref' || - schema.kind === 'array' || - schema.kind === 'record' || - schema.kind === 'union' || - schema.kind === 'intersection' - ? 'json' - : 'text'; -} diff --git a/packages/client-generator/src/emitters/wrapper-support.ts b/packages/client-generator/src/emitters/wrapper-support.ts index 76d5710d02..b880c7f66b 100644 --- a/packages/client-generator/src/emitters/wrapper-support.ts +++ b/packages/client-generator/src/emitters/wrapper-support.ts @@ -9,7 +9,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'; /** * The operations a wrapper generator can wrap, with skips reported to the user under @@ -24,7 +23,7 @@ import { isSseOp } from './sse.js'; */ export function wrappableOperations(model: ApiModel, label: string): OperationModel[] { const all = model.services.flatMap((s) => s.operations); - const sse = all.filter(isSseOp); + const sse = all.filter((op) => op.sse !== undefined); if (sse.length > 0) { logger.warn( `generate-client: ${label} skipped ${sse.length} server-sent-events operation(s) — iterate the sdk's exported async generators directly: ${sse @@ -33,7 +32,7 @@ export function wrappableOperations(model: ApiModel, label: string): OperationMo ); } const schemaNames = new Set(model.schemas.map((s) => s.name)); - const clashing = all.filter((op) => !isSseOp(op) && collides(op, schemaNames)); + const clashing = all.filter((op) => op.sse === undefined && collides(op, schemaNames)); if (clashing.length > 0) { logger.warn( `generate-client: ${label} skipped ${clashing.length} operation(s) whose variables type name collides with a schema — rename the schema or the operation: ${clashing @@ -41,7 +40,7 @@ export function wrappableOperations(model: ApiModel, label: string): OperationMo .join(', ')}.\n` ); } - return all.filter((op) => !isSseOp(op) && !collides(op, schemaNames)); + return all.filter((op) => op.sse === undefined && !collides(op, schemaNames)); } /** Whether the operation's `Variables` type name collides with a named schema. */ diff --git a/packages/client-generator/src/emitters/zod.ts b/packages/client-generator/src/emitters/zod.ts index ced0f512e4..4e52c9e1e2 100644 --- a/packages/client-generator/src/emitters/zod.ts +++ b/packages/client-generator/src/emitters/zod.ts @@ -18,7 +18,6 @@ import { type SchemaModel, } from '../intermediate-representation/model.js'; import { safeIdent } from './identifier.js'; -import { isSseOp } from './sse.js'; import { pascalCase } from './support.js'; import { codeLiteral } from './ts-literal.js'; @@ -221,7 +220,7 @@ type OperationSchemaEntry = { name: string; request?: string; response?: string function operationSchemaEntries(model: ApiModel, byName: SchemaByName): OperationSchemaEntry[] { const entries: OperationSchemaEntry[] = []; for (const op of allOperations(model.services)) { - if (isSseOp(op)) continue; + if (op.sse !== undefined) continue; const requestBody = op.requestBody; const request = requestBody && requestBody.contentType.toLowerCase().includes('json') diff --git a/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts b/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts index e5f4b65df3..981c1d467c 100644 --- a/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts +++ b/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts @@ -1,8 +1,8 @@ import { logger, type Oas3Definition, type Oas3Schema } from '@redocly/openapi-core'; import { NotSupportedError } from '../../errors.js'; -import { buildApiModel } from '../build.js'; -import type { OperationModel, SchemaModel } from '../model.js'; +import { buildApiModel, sseFromResponses } from '../build.js'; +import type { OperationModel, ResponseBodyModel, SchemaModel } from '../model.js'; /** * Build a minimal Oas3Definition wrapper so each test only declares the part it @@ -2195,3 +2195,93 @@ describe('extractMetadata — example/default', () => { expect(got.metadata?.example).toBeUndefined(); }); }); + +/** A success-response list whose entry streams `text/event-stream`. */ +function sseResponses(response: Partial): ResponseBodyModel[] { + return [ + { contentType: 'text/event-stream', schema: { kind: 'unknown' }, ...response, status: 200 }, + ]; +} + +describe('sseFromResponses — detection', () => { + it('is present for a success response with the text/event-stream content type', () => { + expect(sseFromResponses(sseResponses({}))).toBeDefined(); + }); + + it('matches with parameters and is case-insensitive', () => { + expect( + sseFromResponses(sseResponses({ contentType: 'text/event-stream; charset=utf-8' })) + ).toBeDefined(); + expect(sseFromResponses(sseResponses({ contentType: 'Text/Event-Stream' }))).toBeDefined(); + }); + + it('is undefined for a plain JSON operation and for no responses at all', () => { + expect( + sseFromResponses([ + { contentType: 'application/json', schema: { kind: 'ref', name: 'Pet' }, status: 200 }, + ]) + ).toBeUndefined(); + expect(sseFromResponses([])).toBeUndefined(); + }); +}); + +describe('sseFromResponses — eventSchema (drives the streamed payload type)', () => { + it('uses the per-item schema when present', () => { + expect( + sseFromResponses(sseResponses({ itemSchema: { kind: 'ref', name: 'Message' } }))?.eventSchema + ).toEqual({ kind: 'ref', name: 'Message' }); + }); + + it('falls back to the response schema when it is meaningful', () => { + expect( + sseFromResponses(sseResponses({ schema: { kind: 'ref', name: 'Token' } }))?.eventSchema + ).toEqual({ kind: 'ref', name: 'Token' }); + }); + + it('ignores a typeless `itemSchema` and falls back to the response schema', () => { + expect( + sseFromResponses( + sseResponses({ itemSchema: { kind: 'unknown' }, schema: { kind: 'ref', name: 'Token' } }) + )?.eventSchema + ).toEqual({ kind: 'ref', name: 'Token' }); + }); + + it('is undefined when no schema is declared (payload types as `string`)', () => { + expect(sseFromResponses(sseResponses({}))?.eventSchema).toBeUndefined(); + }); +}); + +describe('sseFromResponses — dataKind', () => { + it("is 'json' for object/ref/array/record/union/intersection event types", () => { + const json: SchemaModel[] = [ + { kind: 'object', properties: [] }, + { kind: 'ref', name: 'Message' }, + { kind: 'array', items: { kind: 'scalar', scalar: 'string' } }, + { kind: 'record', value: { kind: 'scalar', scalar: 'string' } }, + { kind: 'union', members: [{ kind: 'ref', name: 'A' }] }, + { kind: 'intersection', members: [{ kind: 'ref', name: 'A' }] }, + ]; + for (const itemSchema of json) { + expect(sseFromResponses(sseResponses({ itemSchema }))?.dataKind).toBe('json'); + } + }); + + it("is 'text' for the string fallback and for a typeless `itemSchema`", () => { + expect(sseFromResponses(sseResponses({}))?.dataKind).toBe('text'); + expect(sseFromResponses(sseResponses({ itemSchema: { kind: 'unknown' } }))?.dataKind).toBe( + 'text' + ); + }); + + it("is 'text' for scalar/literal/enum/null event types", () => { + const text: SchemaModel[] = [ + { kind: 'scalar', scalar: 'string' }, + { kind: 'literal', value: 'x' }, + { kind: 'enum', values: ['a'], scalar: 'string' }, + { kind: 'null' }, + ]; + for (const itemSchema of text) { + expect(sseFromResponses(sseResponses({ itemSchema }))?.dataKind).toBe('text'); + } + }); +}); diff --git a/packages/client-generator/src/intermediate-representation/build.ts b/packages/client-generator/src/intermediate-representation/build.ts index bc45b2a5dd..c58661d8a9 100644 --- a/packages/client-generator/src/intermediate-representation/build.ts +++ b/packages/client-generator/src/intermediate-representation/build.ts @@ -28,6 +28,7 @@ import type { PropertyModel, RequestBodyModel, ResponseBodyModel, + SseModel, ResponseHeaderModel, ScalarKind, SchemaMetadata, @@ -538,6 +539,8 @@ function buildOperation( const extensions = operation as unknown as Record; const paginationExtension = extensions['x-redoclyPagination']; + const sse = sseFromResponses(successResponses); + return { name, method, @@ -555,9 +558,38 @@ function buildOperation( security, tags: Array.isArray(operation.tags) ? operation.tags.filter((t) => typeof t === 'string') : [], ...(paginationExtension !== undefined ? { paginationExtension } : {}), + ...(sse === undefined ? {} : { sse }), }; } +/** + * The operation's SSE facts, from its `text/event-stream` success response (exact media + * type, parameters and case ignored). The event schema prefers the 3.2 `itemSchema` over + * the response `schema`, skipping typeless slots; structured kinds stream as JSON, + * scalar-ish ones as raw text. + */ +export function sseFromResponses(successResponses: ResponseBodyModel[]): SseModel | undefined { + const response = successResponses.find( + (candidate) => candidate.contentType.split(';')[0].trim().toLowerCase() === 'text/event-stream' + ); + if (response === undefined) return undefined; + const declared = + response.itemSchema && response.itemSchema.kind !== 'unknown' + ? response.itemSchema + : response.schema.kind !== 'unknown' + ? response.schema + : undefined; + if (declared === undefined) return { dataKind: 'text' }; + const streamsJson = + declared.kind === 'object' || + declared.kind === 'ref' || + declared.kind === 'array' || + declared.kind === 'record' || + declared.kind === 'union' || + declared.kind === 'intersection'; + return { eventSchema: declared, dataKind: streamsJson ? 'json' : 'text' }; +} + function buildParameter(param: Oas3Parameter, location: string, doc: Oas3Definition): ParamModel { if (!param.in) { throw new NotSupportedError(`Parameter ${param.name} at ${location} is missing "in"`); diff --git a/packages/client-generator/src/intermediate-representation/model.ts b/packages/client-generator/src/intermediate-representation/model.ts index b0c3e08a3f..b5c9a1f22c 100644 --- a/packages/client-generator/src/intermediate-representation/model.ts +++ b/packages/client-generator/src/intermediate-representation/model.ts @@ -227,6 +227,20 @@ export type OperationModel = { * extensions are untyped). Validated by the pagination emitter, not the IR. */ paginationExtension?: unknown; + /** + * Present exactly when the operation streams Server-Sent Events (a `text/event-stream` + * success response). Computed once by the IR builder so no generator re-derives it: + * the per-event schema (OpenAPI 3.2 `itemSchema` over the response `schema`; absent + * when typeless — the payload types as a string) and whether the runtime should + * `JSON.parse` each `data:` payload. + */ + sse?: SseModel; +}; + +/** An SSE operation's streaming facts (see `OperationModel.sse`). */ +export type SseModel = { + eventSchema?: SchemaModel; + dataKind: 'json' | 'text'; }; export type ServiceModel = { From 5d344d9a4dba7bc6f06687790c5c864c6fe87cff Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 21:34:11 +0300 Subject: [PATCH 17/35] refactor: hand generators a parsed output anchor and the banner lines instead of a raw path and a shared HEADER --- .../@v2/guides/customize-client-generation.md | 12 ++++---- packages/client-generator/README.md | 4 +-- .../client-generator/eject-assets/AGENTS.md | 12 ++++---- .../skills/client-generators/SKILL.md | 12 ++++---- .../src/generators/__tests__/cli.test.ts | 7 ++++- .../__tests__/fixtures/generator-input.ts | 28 +++++++++++++++++++ .../__tests__/fixtures/route-map-plugin.ts | 4 +-- .../__tests__/generator-options.test.ts | 4 +-- .../src/generators/__tests__/go.test.ts | 10 +++---- .../src/generators/__tests__/mock.test.ts | 7 ++++- .../src/generators/__tests__/php.test.ts | 10 +++---- .../src/generators/__tests__/python.test.ts | 10 +++---- .../src/generators/__tests__/swr.test.ts | 9 ++++-- .../__tests__/tanstack-query.test.ts | 13 +++++++-- .../generators/__tests__/transformers.test.ts | 9 ++++-- .../generators/__tests__/typescript.test.ts | 7 ++++- .../src/generators/__tests__/zod.test.ts | 7 ++++- .../client-generator/src/generators/anchor.ts | 10 ------- .../src/generators/cli/index.ts | 15 ++++------ .../src/generators/go/index.ts | 10 +++---- .../src/generators/mock/index.ts | 12 ++++---- .../src/generators/php/index.ts | 10 +++---- .../src/generators/python/index.ts | 10 +++---- .../src/generators/swr/index.ts | 10 +++---- .../src/generators/tanstack-query/index.ts | 12 ++++---- .../src/generators/transformers/index.ts | 15 ++++++---- .../client-generator/src/generators/types.ts | 16 +++++++++-- .../src/generators/typescript/index.ts | 15 +++++----- .../src/generators/zod/index.ts | 8 ++---- packages/client-generator/src/pipeline.ts | 15 ++++++++-- packages/client-generator/src/plugin.ts | 4 +-- .../custom-generator/route-map-generator.mjs | 6 ++-- .../.claude/skills/client-generators/SKILL.md | 12 ++++---- .../ejected-generator/generators/php.mjs | 4 +-- .../nested-facade/nested-facade-generator.mjs | 8 +++--- .../response-map-generator.mjs | 6 ++-- .../valibot-schema-generator.mjs | 6 ++-- .../fixtures/route-map-plugin.mjs | 4 +-- 38 files changed, 223 insertions(+), 150 deletions(-) create mode 100644 packages/client-generator/src/generators/__tests__/fixtures/generator-input.ts delete mode 100644 packages/client-generator/src/generators/anchor.ts diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 694cb70932..c21aa32aaa 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -142,7 +142,7 @@ export default defineGenerator({ properties: { groupBy: { enum: ['tag', 'path'], default: 'tag' } }, additionalProperties: false, }, - run({ model, outputPath, options }) { + run({ model, output, options }) { // `options` is validated against the schema before `run` is called. }, }); @@ -214,7 +214,7 @@ import { tsType } from '@redocly/client-generator/generate'; export default { name: 'response-map', requires: ['typescript'], - run({ model, outputPath }) { + run({ model, output }) { const members = model.services .flatMap((service) => service.operations) .flatMap((op) => { @@ -223,7 +223,7 @@ export default { }); return [ { - path: outputPath.replace(/\.ts$/, '.responses.ts'), + path: output.path.replace(/\.ts$/, '.responses.ts'), content: `export type ResponseShapes = {\n${members.join('\n')}\n};\n`, }, ]; @@ -295,14 +295,14 @@ const rubyCall = (operation) => ({ lang: 'ruby', source: `client.${operation.nam export default defineGenerator({ name: 'ruby', - run({ model, outputPath }) { + run({ model, output }) { /* the SDK */ }, sample: rubyCall, - docs({ model, outputPath, emit }) { + docs({ model, output, emit }) { return [ { - path: outputPath.replace(/\.[^.\\/]+$/, '.ruby.md'), + path: output.path.replace(/\.[^.\\/]+$/, '.ruby.md'), content: renderReferencePage(model, { title: `${model.title} Ruby SDK reference`, frontmatter: emit.docsFrontmatter === true, diff --git a/packages/client-generator/README.md b/packages/client-generator/README.md index 7f878dd962..31a2d7bb53 100644 --- a/packages/client-generator/README.md +++ b/packages/client-generator/README.md @@ -65,7 +65,7 @@ import { tsType } from '@redocly/client-generator/generate'; export default defineGenerator({ name: 'response-map', requires: ['typescript'], - run({ model, outputPath }) { + run({ model, output }) { const printer = new Printer(); // One `ResponseShapes` entry per operation with a JSON success body. printer.block( @@ -80,7 +80,7 @@ export default defineGenerator({ }, '};' ); - return [{ path: outputPath.replace(/\.ts$/, '.responses.ts'), content: printer.toString() }]; + return [{ path: output.path.replace(/\.ts$/, '.responses.ts'), content: printer.toString() }]; }, }); ``` diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md index dc77c9384a..f8b8ae09e0 100644 --- a/packages/client-generator/eject-assets/AGENTS.md +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -15,8 +15,8 @@ client: /** @type {import('@redocly/client-generator').CustomGenerator} */ export default { name: 'my-generator', - run({ model, outputPath, outputMode, emit }) { - return [{ path: outputPath.replace(/\.ts$/, '.mine.txt'), content: '…' }]; + run({ model, output, outputMode, emit }) { + return [{ path: output.path.replace(/\.ts$/, '.mine.txt'), content: '…' }]; }, // Optional: one idiomatic call snippet per operation for docs (x-codeSamples), // collected into an overlay file when `client.codeSamples: true` is set. @@ -26,8 +26,8 @@ export default { // Optional: the reference page for what `run` emits, written when `client.docs` (or // --docs) is on. Same `{ path, content }` shape as `run`; `renderReferencePage` gives // the standard layout and takes `sample` for its snippets. A generator documents itself. - docs({ model, outputPath, emit }) { - return [{ path: outputPath.replace(/\.ts$/, '.mine.md'), content: '…' }]; + docs({ model, output, emit }) { + return [{ path: output.path.replace(/\.ts$/, '.mine.md'), content: '…' }]; }, }; ``` @@ -45,9 +45,9 @@ export default { properties: { groupBy: { enum: ['tag', 'path'], default: 'tag' } }, additionalProperties: false, }, - run({ model, outputPath, options }) { + run({ model, output, options }) { return [ - { path: outputPath.replace(/\.ts$/, '.permissions.md'), content: render(options.groupBy) }, + { path: output.path.replace(/\.ts$/, '.permissions.md'), content: render(options.groupBy) }, ]; }, }; diff --git a/packages/client-generator/eject-assets/skills/client-generators/SKILL.md b/packages/client-generator/eject-assets/skills/client-generators/SKILL.md index 1b39b8489c..eec5673b4f 100644 --- a/packages/client-generator/eject-assets/skills/client-generators/SKILL.md +++ b/packages/client-generator/eject-assets/skills/client-generators/SKILL.md @@ -20,8 +20,8 @@ client: /** @type {import('@redocly/client-generator').CustomGenerator} */ export default { name: 'my-generator', - run({ model, outputPath, outputMode, emit }) { - return [{ path: outputPath.replace(/\.ts$/, '.mine.txt'), content: '…' }]; + run({ model, output, outputMode, emit }) { + return [{ path: output.path.replace(/\.ts$/, '.mine.txt'), content: '…' }]; }, // Optional: one idiomatic call snippet per operation for docs (x-codeSamples), // collected into an overlay file when `client.codeSamples: true` is set. @@ -31,8 +31,8 @@ export default { // Optional: the reference page for what `run` emits, written when `client.docs` (or // --docs) is on. Same `{ path, content }` shape as `run`; `renderReferencePage` gives // the standard layout and takes `sample` for its snippets. A generator documents itself. - docs({ model, outputPath, emit }) { - return [{ path: outputPath.replace(/\.ts$/, '.mine.md'), content: '…' }]; + docs({ model, output, emit }) { + return [{ path: output.path.replace(/\.ts$/, '.mine.md'), content: '…' }]; }, }; ``` @@ -50,9 +50,9 @@ export default { properties: { groupBy: { enum: ['tag', 'path'], default: 'tag' } }, additionalProperties: false, }, - run({ model, outputPath, options }) { + run({ model, output, options }) { return [ - { path: outputPath.replace(/\.ts$/, '.permissions.md'), content: render(options.groupBy) }, + { path: output.path.replace(/\.ts$/, '.permissions.md'), content: render(options.groupBy) }, ]; }, }; diff --git a/packages/client-generator/src/generators/__tests__/cli.test.ts b/packages/client-generator/src/generators/__tests__/cli.test.ts index b33eca6fed..efe9a57916 100644 --- a/packages/client-generator/src/generators/__tests__/cli.test.ts +++ b/packages/client-generator/src/generators/__tests__/cli.test.ts @@ -1,6 +1,11 @@ import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; -import { cliGenerator, cliSample } from '../cli/index.js'; +import { cliGenerator as cliGeneratorEntry, cliSample } from '../cli/index.js'; import { builtinGenerators, validateGenerators } from '../index.js'; +import { generatorInput } from './fixtures/generator-input.js'; + +// The pipeline parses the output anchor; `generatorInput` mirrors it for direct calls. +const cliGenerator = (input: Parameters[0]) => + cliGeneratorEntry(generatorInput(input)); const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' }; diff --git a/packages/client-generator/src/generators/__tests__/fixtures/generator-input.ts b/packages/client-generator/src/generators/__tests__/fixtures/generator-input.ts new file mode 100644 index 0000000000..d444a47041 --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/fixtures/generator-input.ts @@ -0,0 +1,28 @@ +import { parse } from 'node:path'; + +import { resolveModelPagination } from '../../../emitters/pagination.js'; +import type { GeneratorInput } from '../../types.js'; + +/** The banner lines the pipeline derives from `HEADER` for every run. */ +export const BANNER = [ + 'Generated by @redocly/client-generator — do not edit by hand.', + 'Source: OpenAPI description. Re-run `redocly generate-client` to update.', +]; + +/** + * Build a `GeneratorInput` the way the pipeline does: parse the `--output` anchor, + * stamp the banner, and resolve pagination once. Tests call generators directly, so + * they mirror those steps here. + */ +export function generatorInput( + overrides: Omit & { outputPath: string } +): GeneratorInput { + const { outputPath, ...rest } = overrides; + const { dir, name: stem, ext } = parse(outputPath); + return { + ...rest, + output: { path: outputPath, dir, stem, ext }, + banner: BANNER, + pagination: resolveModelPagination(overrides.model, undefined), + }; +} diff --git a/packages/client-generator/src/generators/__tests__/fixtures/route-map-plugin.ts b/packages/client-generator/src/generators/__tests__/fixtures/route-map-plugin.ts index a5009cff29..446fb37180 100644 --- a/packages/client-generator/src/generators/__tests__/fixtures/route-map-plugin.ts +++ b/packages/client-generator/src/generators/__tests__/fixtures/route-map-plugin.ts @@ -4,14 +4,14 @@ import type { CustomGenerator } from '../../types.js'; const generator: CustomGenerator = { name: 'route-map', requires: ['typescript'], - run({ model, outputPath }) { + run({ model, output }) { const routes = model.services .flatMap((s) => s.operations) .map((op) => ` ${op.name}: '${op.method.toUpperCase()} ${op.path}',`) .join('\n'); return [ { - path: outputPath.replace(/\.ts$/, '.routes.ts'), + path: output.path.replace(/\.ts$/, '.routes.ts'), content: `export const routes = {\n${routes}\n} as const;\n`, }, ]; diff --git a/packages/client-generator/src/generators/__tests__/generator-options.test.ts b/packages/client-generator/src/generators/__tests__/generator-options.test.ts index c3aa0e0f07..669f56c901 100644 --- a/packages/client-generator/src/generators/__tests__/generator-options.test.ts +++ b/packages/client-generator/src/generators/__tests__/generator-options.test.ts @@ -98,9 +98,9 @@ describe('runGenerators', () => { 'permissions-matrix', { options: MATRIX_SCHEMA, - run: ({ options, outputPath }) => { + run: ({ options, output }) => { seen = options; - return [{ path: outputPath.replace(/\.ts$/, '.permissions.md'), content: '' }]; + return [{ path: output.path.replace(/\.ts$/, '.permissions.md'), content: '' }]; }, }, ], diff --git a/packages/client-generator/src/generators/__tests__/go.test.ts b/packages/client-generator/src/generators/__tests__/go.test.ts index 99224991e8..b242498a16 100644 --- a/packages/client-generator/src/generators/__tests__/go.test.ts +++ b/packages/client-generator/src/generators/__tests__/go.test.ts @@ -3,14 +3,14 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { resolveModelPagination } from '../../emitters/pagination.js'; import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; import { goGenerator as goGeneratorEntry, goSample, renderGoModels } from '../go/index.js'; +import { generatorInput } from './fixtures/generator-input.js'; -// The pipeline resolves pagination once and hands generators the map; these direct -// calls mirror that step. -const goGenerator = (input: Omit[0], 'pagination'>) => - goGeneratorEntry({ ...input, pagination: resolveModelPagination(input.model, undefined) }); +// The pipeline parses the output anchor and resolves pagination once; `generatorInput` +// mirrors those steps for these direct calls. +const goGenerator = (input: Parameters[0]) => + goGeneratorEntry(generatorInput(input)); const hasGo = spawnSync('go', ['version']).status === 0; diff --git a/packages/client-generator/src/generators/__tests__/mock.test.ts b/packages/client-generator/src/generators/__tests__/mock.test.ts index 53a00049ff..36ae532485 100644 --- a/packages/client-generator/src/generators/__tests__/mock.test.ts +++ b/packages/client-generator/src/generators/__tests__/mock.test.ts @@ -1,5 +1,10 @@ import { apiModel, namedSchema, operation, response } from '../../emitters/__tests__/fixtures.js'; -import { mockGenerator } from '../mock/index.js'; +import { mockGenerator as mockGeneratorEntry } from '../mock/index.js'; +import { generatorInput } from './fixtures/generator-input.js'; + +// The pipeline parses the output anchor; `generatorInput` mirrors it for direct calls. +const mockGenerator = (input: Parameters[0]) => + mockGeneratorEntry(generatorInput(input)); describe('mockGenerator', () => { it('returns [] for a model with no operations', () => { diff --git a/packages/client-generator/src/generators/__tests__/php.test.ts b/packages/client-generator/src/generators/__tests__/php.test.ts index 464c1f8021..0acdc1ffc2 100644 --- a/packages/client-generator/src/generators/__tests__/php.test.ts +++ b/packages/client-generator/src/generators/__tests__/php.test.ts @@ -3,7 +3,6 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { resolveModelPagination } from '../../emitters/pagination.js'; import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; import { phpGenerator as phpGeneratorEntry, @@ -11,11 +10,12 @@ import { phpType, renderPhpModels, } from '../php/index.js'; +import { generatorInput } from './fixtures/generator-input.js'; -// The pipeline resolves pagination once and hands generators the map; these direct -// calls mirror that step. -const phpGenerator = (input: Omit[0], 'pagination'>) => - phpGeneratorEntry({ ...input, pagination: resolveModelPagination(input.model, undefined) }); +// The pipeline parses the output anchor and resolves pagination once; `generatorInput` +// mirrors those steps for these direct calls. +const phpGenerator = (input: Parameters[0]) => + phpGeneratorEntry(generatorInput(input)); const hasPhp = spawnSync('php', ['--version']).status === 0; diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts index a71360ac0c..aacba0616f 100644 --- a/packages/client-generator/src/generators/__tests__/python.test.ts +++ b/packages/client-generator/src/generators/__tests__/python.test.ts @@ -3,14 +3,14 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { resolveModelPagination } from '../../emitters/pagination.js'; import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; import { pythonGenerator as pythonGeneratorEntry, renderPythonModels } from '../python/index.js'; +import { generatorInput } from './fixtures/generator-input.js'; -// The pipeline resolves pagination once and hands generators the map; these direct -// calls mirror that step. -const pythonGenerator = (input: Omit[0], 'pagination'>) => - pythonGeneratorEntry({ ...input, pagination: resolveModelPagination(input.model, undefined) }); +// The pipeline parses the output anchor and resolves pagination once; `generatorInput` +// mirrors those steps for these direct calls. +const pythonGenerator = (input: Parameters[0]) => + pythonGeneratorEntry(generatorInput(input)); const hasPython = spawnSync('python3', ['--version']).status === 0; const hasHttpx = hasPython && spawnSync('python3', ['-c', 'import httpx']).status === 0; diff --git a/packages/client-generator/src/generators/__tests__/swr.test.ts b/packages/client-generator/src/generators/__tests__/swr.test.ts index 818893d1b0..b68eabc6b7 100644 --- a/packages/client-generator/src/generators/__tests__/swr.test.ts +++ b/packages/client-generator/src/generators/__tests__/swr.test.ts @@ -1,6 +1,11 @@ import { apiModel, operation } from '../../emitters/__tests__/fixtures.js'; import { builtinGenerators } from '../index.js'; -import { swrGenerator } from '../swr/index.js'; +import { swrGenerator as swrGeneratorEntry } from '../swr/index.js'; +import { generatorInput } from './fixtures/generator-input.js'; + +// The pipeline parses the output anchor; `generatorInput` mirrors it for direct calls. +const swrGenerator = (input: Parameters[0]) => + swrGeneratorEntry(generatorInput(input)); const SERVICES = [ { @@ -41,6 +46,6 @@ describe('swrGenerator', () => { }); it('is registered under "swr"', () => { - expect(builtinGenerators().get('swr')?.run).toBe(swrGenerator); + expect(builtinGenerators().get('swr')?.run).toBe(swrGeneratorEntry); }); }); diff --git a/packages/client-generator/src/generators/__tests__/tanstack-query.test.ts b/packages/client-generator/src/generators/__tests__/tanstack-query.test.ts index 7e188b6e2a..cd19ab62d7 100644 --- a/packages/client-generator/src/generators/__tests__/tanstack-query.test.ts +++ b/packages/client-generator/src/generators/__tests__/tanstack-query.test.ts @@ -1,6 +1,13 @@ import { apiModel, operation } from '../../emitters/__tests__/fixtures.js'; import { builtinGenerators } from '../index.js'; -import { tanstackQueryGenerator } from '../tanstack-query/index.js'; +import { tanstackQueryGenerator as tanstackQueryGeneratorEntry } from '../tanstack-query/index.js'; +import { generatorInput } from './fixtures/generator-input.js'; + +// The pipeline parses the output anchor; `generatorInput` mirrors it for direct calls. +const tanstackQueryGenerator = + (framework: Parameters[0]) => + (input: Parameters[0]) => + tanstackQueryGeneratorEntry(framework)(generatorInput(input)); const SERVICES = [ { @@ -51,12 +58,12 @@ describe('tanstackQueryGenerator', () => { it('binds the framework per registry name — bare tanstack-query stays React', () => { const registry = builtinGenerators(); - const input = { + const input = generatorInput({ model: apiModel({ services: SERVICES }), outputPath: '/tmp/out/client.ts', outputMode: 'single' as const, emit: {}, - }; + }); const importOf = (name: string) => registry .get(name)! diff --git a/packages/client-generator/src/generators/__tests__/transformers.test.ts b/packages/client-generator/src/generators/__tests__/transformers.test.ts index 52b8c2d3c0..dae5517c8d 100644 --- a/packages/client-generator/src/generators/__tests__/transformers.test.ts +++ b/packages/client-generator/src/generators/__tests__/transformers.test.ts @@ -1,6 +1,11 @@ import { apiModel, namedSchema } from '../../emitters/__tests__/fixtures.js'; import { builtinGenerators } from '../index.js'; -import { transformersGenerator } from '../transformers/index.js'; +import { transformersGenerator as transformersGeneratorEntry } from '../transformers/index.js'; +import { generatorInput } from './fixtures/generator-input.js'; + +// The pipeline parses the output anchor; `generatorInput` mirrors it for direct calls. +const transformersGenerator = (input: Parameters[0]) => + transformersGeneratorEntry(generatorInput(input)); const EVENT = namedSchema('Event', { kind: 'object', @@ -74,6 +79,6 @@ describe('transformersGenerator', () => { }); it('is registered under "transformers"', () => { - expect(builtinGenerators().get('transformers')?.run).toBe(transformersGenerator); + expect(builtinGenerators().get('transformers')?.run).toBe(transformersGeneratorEntry); }); }); diff --git a/packages/client-generator/src/generators/__tests__/typescript.test.ts b/packages/client-generator/src/generators/__tests__/typescript.test.ts index db6fb923e0..935cf8ab02 100644 --- a/packages/client-generator/src/generators/__tests__/typescript.test.ts +++ b/packages/client-generator/src/generators/__tests__/typescript.test.ts @@ -1,6 +1,11 @@ import { HEADER } from '../../emitters/emit-options.js'; import type { ApiModel } from '../../intermediate-representation/model.js'; -import { typescriptGenerator } from '../typescript/index.js'; +import { typescriptGenerator as typescriptGeneratorEntry } from '../typescript/index.js'; +import { generatorInput } from './fixtures/generator-input.js'; + +// The pipeline parses the output anchor; `generatorInput` mirrors it for direct calls. +const typescriptGenerator = (input: Parameters[0]) => + typescriptGeneratorEntry(generatorInput(input)); function apiModel(): ApiModel { return { diff --git a/packages/client-generator/src/generators/__tests__/zod.test.ts b/packages/client-generator/src/generators/__tests__/zod.test.ts index d6029cee6a..d3c4ca5792 100644 --- a/packages/client-generator/src/generators/__tests__/zod.test.ts +++ b/packages/client-generator/src/generators/__tests__/zod.test.ts @@ -1,5 +1,10 @@ import { apiModel, namedSchema } from '../../emitters/__tests__/fixtures.js'; -import { zodGenerator } from '../zod/index.js'; +import { zodGenerator as zodGeneratorEntry } from '../zod/index.js'; +import { generatorInput } from './fixtures/generator-input.js'; + +// The pipeline parses the output anchor; `generatorInput` mirrors it for direct calls. +const zodGenerator = (input: Parameters[0]) => + zodGeneratorEntry(generatorInput(input)); const PET = namedSchema('Pet', { kind: 'object', diff --git a/packages/client-generator/src/generators/anchor.ts b/packages/client-generator/src/generators/anchor.ts deleted file mode 100644 index ca629642b6..0000000000 --- a/packages/client-generator/src/generators/anchor.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { parse } from 'node:path'; - -/** - * Derive the directory and base name (stem, without `.ts`) from the `--output` - * anchor path. Generators build sibling-file paths from these. - */ -export function anchor(outputPath: string): { dir: string; stem: string } { - const { dir, name } = parse(outputPath); - return { dir, stem: name }; -} diff --git a/packages/client-generator/src/generators/cli/index.ts b/packages/client-generator/src/generators/cli/index.ts index 4fda52b0c1..ec543d881d 100644 --- a/packages/client-generator/src/generators/cli/index.ts +++ b/packages/client-generator/src/generators/cli/index.ts @@ -4,7 +4,6 @@ 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'; /** @@ -13,17 +12,16 @@ import type { CodeSample, Generator, SampleContext } from '../types.js'; * bodies, env auth, `--page-all`, SSE/blob output, a documented exit-code * contract). Requires `typescript` (throw mode); wires zod validation when co-selected. */ -export const cliGenerator: Generator = ({ model, outputPath, emit, selected, pagination }) => { - const { dir, stem } = anchor(outputPath); +export const cliGenerator: Generator = ({ model, output, emit, selected, pagination }) => { const content = renderCliModule(model, { - stem, + stem: output.stem, importExt: emit.importExt ?? 'js', runtime: emit.runtime ?? 'inline', zodSelected: selected?.includes('zod') ?? false, pagination, argsStyle: emit.argsStyle ?? 'grouped', }); - return [{ path: join(dir, `${stem}.cli.ts`), content }]; + return [{ path: join(output.dir, `${output.stem}.cli.ts`), content }]; }; /** @@ -32,15 +30,14 @@ export const cliGenerator: Generator = ({ model, outputPath, emit, selected, pag * 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, pagination }) => { - const { dir, stem } = anchor(outputPath); +export const cliDocs: Generator = ({ model, output, emit, pagination }) => { const content = renderCliDocs(commandData(model, { pagination }), { title: `${model.title} command-line reference`, frontmatter: emit.docsFrontmatter === true, - name: stem, + name: output.stem, schemes: cliAuthSchemes(model), }); - return [{ path: join(dir, `${stem}.cli.md`), content }]; + return [{ path: join(output.dir, `${output.stem}.cli.md`), content }]; }; /** One shell invocation per operation — feeds `x-codeSamples` for docs. */ diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index 2491f7cbc0..be91f0224d 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -826,7 +826,7 @@ function writeGoServers(printer: GoPrinter, model: ApiModel): void { } /** The whole generated file: models + embedded runtime + operations table + Client. */ -export const goGenerator: Generator = ({ model, outputPath, emit, pagination }) => { +export const goGenerator: Generator = ({ model, output, emit, pagination }) => { const printer = new GoPrinter(); const dateType = emit.dateType ?? 'string'; const packageName = goPackageName(emit.goPackage); @@ -978,7 +978,7 @@ export const goGenerator: Generator = ({ model, outputPath, emit, pagination }) return [ { - path: outputPath.replace(/\.[^.\\/]+$/, '.go'), + path: output.path.replace(/\.[^.\\/]+$/, '.go'), // Sections are stitched with their own trailing blanks; gofmt allows at most one // between declarations and none at the end of the file. content: printer.toString(), @@ -1025,9 +1025,9 @@ export function goSample(op: OperationModel, ctx: SampleContext): CodeSample { * from `goSample` — this generator's own hook — so the page can only ever show the syntax * of the SDK beside it, and ejecting this generator takes the page with it. */ -export const goDocs: Generator = ({ model, outputPath, emit, pagination }) => [ +export const goDocs: Generator = ({ model, output, emit, pagination }) => [ { - path: outputPath.replace(/\.[^.\\/]+$/, '.go.md'), + path: output.path.replace(/\.[^.\\/]+$/, '.go.md'), content: renderReferencePage(model, { title: `${model.title} Go SDK reference`, frontmatter: emit.docsFrontmatter === true, @@ -1037,7 +1037,7 @@ export const goDocs: Generator = ({ model, outputPath, emit, pagination }) => [ fence: 'go', requires: 'The SDK needs the standard library only.', }, - sample: (op) => goSample(op, { model, emit, outputPath }), + sample: (op) => goSample(op, { model, emit, outputPath: output.path }), paginated: new Set(pagination?.keys() ?? []), }), }, diff --git a/packages/client-generator/src/generators/mock/index.ts b/packages/client-generator/src/generators/mock/index.ts index f139fff4ec..01f7e92ceb 100644 --- a/packages/client-generator/src/generators/mock/index.ts +++ b/packages/client-generator/src/generators/mock/index.ts @@ -1,8 +1,6 @@ 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'; /** @@ -11,14 +9,16 @@ import type { Generator } from '../types.js'; * sdk client stays dependency-free. Output-mode-agnostic in v1 — one module beside * the client. Emits nothing when there are no operations. */ -export const mockGenerator: Generator = ({ model, outputPath, emit }) => { - const { dir, stem } = anchor(outputPath); +export const mockGenerator: Generator = ({ model, output, banner, emit }) => { + const header = banner.map((line) => `// ${line}`).join('\n'); const content = renderMockModule(model, { - sdkModule: `./${stem}.${emit.importExt ?? 'js'}`, + sdkModule: `./${output.stem}.${emit.importExt ?? 'js'}`, dateType: emit.dateType, mockData: emit.mockData, mockSeed: emit.mockSeed, }); if (content === '') return []; - return [{ path: join(dir, `${stem}.mocks.ts`), content: `${HEADER}\n\n${content}` }]; + return [ + { path: join(output.dir, `${output.stem}.mocks.ts`), content: `${header}\n\n${content}` }, + ]; }; diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index 22f0f0146c..cb4803cda6 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -881,7 +881,7 @@ function stripPhpHeader(source: string): string { } /** The whole generated file: namespace + models + embedded runtime + operations + Client. */ -export const phpGenerator: Generator = ({ model, outputPath, emit, pagination }) => { +export const phpGenerator: Generator = ({ model, output, emit, pagination }) => { const printer = new PhpPrinter(); const dateType = emit.dateType ?? 'string'; const namespace = identifierFor(model.title, { style: 'pascal', reserved: PHP }); @@ -988,7 +988,7 @@ export const phpGenerator: Generator = ({ model, outputPath, emit, pagination }) '}' ); - return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.php'), content: printer.toString() }]; + return [{ path: output.path.replace(/\.[^.\\/]+$/, '.php'), content: printer.toString() }]; }; /** One idiomatic PHP call per operation — feeds `x-codeSamples` for docs. */ @@ -1015,9 +1015,9 @@ export function phpSample(op: OperationModel, ctx: SampleContext): CodeSample { * from `phpSample` — this generator's own hook — so the page can only ever show the syntax * of the SDK beside it, and ejecting this generator takes the page with it. */ -export const phpDocs: Generator = ({ model, outputPath, emit, pagination }) => [ +export const phpDocs: Generator = ({ model, output, emit, pagination }) => [ { - path: outputPath.replace(/\.[^.\\/]+$/, '.php.md'), + path: output.path.replace(/\.[^.\\/]+$/, '.php.md'), content: renderReferencePage(model, { title: `${model.title} PHP SDK reference`, frontmatter: emit.docsFrontmatter === true, @@ -1027,7 +1027,7 @@ export const phpDocs: Generator = ({ model, outputPath, emit, pagination }) => [ fence: 'php', requires: 'The SDK needs the curl extension.', }, - sample: (op) => phpSample(op, { model, emit, outputPath }), + sample: (op) => phpSample(op, { model, emit, outputPath: output.path }), paginated: new Set(pagination?.keys() ?? []), }), }, diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index 8b0b145e69..c6b3440b59 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -730,7 +730,7 @@ function pythonModulePath(outputPath: string): string { } /** The whole generated file: header, models, embedded runtime, descriptors, clients. */ -export const pythonGenerator: Generator = ({ model, outputPath, emit, options, pagination }) => { +export const pythonGenerator: Generator = ({ model, output, emit, options, pagination }) => { const errorMode = emit.errorMode ?? 'throw'; const dateType = emit.dateType ?? 'string'; const models = (options?.models as PythonModels | undefined) ?? 'dataclass'; @@ -814,7 +814,7 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit, options, p writeClientClass(printer, model, errorMode, false, paginationSpecs, serverUrl, dateType); writeClientClass(printer, model, errorMode, true, paginationSpecs, serverUrl, dateType); - return [{ path: pythonModulePath(outputPath), content: printer.toString() }]; + return [{ path: pythonModulePath(output.path), content: printer.toString() }]; }; /** One idiomatic Python call per operation — feeds `x-codeSamples` for docs. */ @@ -854,9 +854,9 @@ export function pythonSample(op: OperationModel, ctx: SampleContext): CodeSample * from `pythonSample` — this generator's own hook — so the page can only ever show the syntax * of the SDK beside it, and ejecting this generator takes the page with it. */ -export const pythonDocs: Generator = ({ model, outputPath, emit, pagination }) => [ +export const pythonDocs: Generator = ({ model, output, emit, pagination }) => [ { - path: outputPath.replace(/\.[^.\\/]+$/, '.python.md'), + path: output.path.replace(/\.[^.\\/]+$/, '.python.md'), content: renderReferencePage(model, { title: `${model.title} Python SDK reference`, frontmatter: emit.docsFrontmatter === true, @@ -866,7 +866,7 @@ export const pythonDocs: Generator = ({ model, outputPath, emit, pagination }) = fence: 'python', requires: 'The SDK needs `httpx`.', }, - sample: (op) => pythonSample(op, { model, emit, outputPath }), + sample: (op) => pythonSample(op, { model, emit, outputPath: output.path }), paginated: new Set(pagination?.keys() ?? []), }), }, diff --git a/packages/client-generator/src/generators/swr/index.ts b/packages/client-generator/src/generators/swr/index.ts index e6443c6d52..49b41b8614 100644 --- a/packages/client-generator/src/generators/swr/index.ts +++ b/packages/client-generator/src/generators/swr/index.ts @@ -1,8 +1,6 @@ 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'; /** @@ -17,11 +15,11 @@ import type { Generator } from '../types.js'; * multi-file barrel at the output anchor either way. Emits nothing when there are * no operations. */ -export const swrGenerator: Generator = ({ model, outputPath, emit }) => { - const { dir, stem } = anchor(outputPath); +export const swrGenerator: Generator = ({ model, output, banner, emit }) => { const content = renderSwrModule(model, { - sdkModule: `./${stem}.${emit.importExt ?? 'js'}`, + sdkModule: `./${output.stem}.${emit.importExt ?? 'js'}`, }); if (content === '') return []; - return [{ path: join(dir, `${stem}.swr.ts`), content: `${HEADER}\n\n${content}` }]; + const header = banner.map((line) => `// ${line}`).join('\n'); + return [{ path: join(output.dir, `${output.stem}.swr.ts`), content: `${header}\n\n${content}` }]; }; diff --git a/packages/client-generator/src/generators/tanstack-query/index.ts b/packages/client-generator/src/generators/tanstack-query/index.ts index 115a084877..5d9553f75d 100644 --- a/packages/client-generator/src/generators/tanstack-query/index.ts +++ b/packages/client-generator/src/generators/tanstack-query/index.ts @@ -1,8 +1,6 @@ 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'; /** @@ -21,16 +19,18 @@ import type { Generator } from '../types.js'; * no operations. */ export function tanstackQueryGenerator(framework: 'react' | 'vue' | 'svelte' | 'solid'): Generator { - return ({ model, outputPath, emit, pagination }) => { - const { dir, stem } = anchor(outputPath); + return ({ model, output, banner, emit, pagination }) => { const content = renderTanstackModule(model, { argsStyle: emit.argsStyle ?? 'grouped', - sdkModule: `./${stem}.${emit.importExt ?? 'js'}`, + sdkModule: `./${output.stem}.${emit.importExt ?? 'js'}`, framework, pagination, queryKeyPrefix: emit.queryKeyPrefix, }); if (content === '') return []; - return [{ path: join(dir, `${stem}.tanstack.ts`), content: `${HEADER}\n\n${content}` }]; + const header = banner.map((line) => `// ${line}`).join('\n'); + return [ + { path: join(output.dir, `${output.stem}.tanstack.ts`), content: `${header}\n\n${content}` }, + ]; }; } diff --git a/packages/client-generator/src/generators/transformers/index.ts b/packages/client-generator/src/generators/transformers/index.ts index cb71f963d3..3ecbe46017 100644 --- a/packages/client-generator/src/generators/transformers/index.ts +++ b/packages/client-generator/src/generators/transformers/index.ts @@ -1,8 +1,6 @@ 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'; /** @@ -21,11 +19,16 @@ import type { Generator } from '../types.js'; * beside the client regardless of how the sdk partitions its files. Emits * nothing when no schema has a date field (nothing to transform). */ -export const transformersGenerator: Generator = ({ model, outputPath, emit }) => { - const { dir, stem } = anchor(outputPath); +export const transformersGenerator: Generator = ({ model, output, banner, emit }) => { const content = renderTransformersModule(model, { - sdkModule: `./${stem}.${emit.importExt ?? 'js'}`, + sdkModule: `./${output.stem}.${emit.importExt ?? 'js'}`, }); if (content === '') return []; - return [{ path: join(dir, `${stem}.transformers.ts`), content: `${HEADER}\n\n${content}` }]; + const header = banner.map((line) => `// ${line}`).join('\n'); + return [ + { + path: join(output.dir, `${output.stem}.transformers.ts`), + content: `${header}\n\n${content}`, + }, + ]; }; diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index 58a7423a56..1a465f02c7 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -53,11 +53,23 @@ export type GeneratorOptionsSchema = { additionalProperties?: boolean; }; +/** + * The `--output` anchor, parsed once by the pipeline: the full `path`, its `dir`, + * the `stem` (base name without the final extension), and the `ext` (with the dot). + * Generators derive sibling-file names from these instead of re-parsing the path. + */ +export type OutputAnchor = { path: string; dir: string; stem: string; ext: string }; + /** Everything a generator needs to produce its files. */ export type GeneratorInput = { model: ApiModel; - /** The `--output` anchor path. */ - outputPath: string; + /** The `--output` anchor, parsed (see `OutputAnchor`). */ + output: OutputAnchor; + /** + * The generated-by banner lines, free of comment markers — each generator prepends + * them in its own comment syntax, so every emitted file says the same thing. + */ + banner: string[]; /** * Pagination resolved ONCE by the pipeline — per-op config > `x-redoclyPagination` > * convention, fit-verified, pointers resolved — keyed by operation name. Generators diff --git a/packages/client-generator/src/generators/typescript/index.ts b/packages/client-generator/src/generators/typescript/index.ts index 65a24dcff4..e8deb8fdfd 100644 --- a/packages/client-generator/src/generators/typescript/index.ts +++ b/packages/client-generator/src/generators/typescript/index.ts @@ -4,7 +4,6 @@ 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'; /** @@ -16,18 +15,18 @@ import type { CodeSample, Generator, SampleContext } from '../types.js'; * const-objects, type guards; skipped when the document declares no schemas) and * `.ts` (everything else, which `export *`s the schemas module). */ -export const typescriptGenerator: Generator = ({ model, outputPath, outputMode, emit }) => { +export const typescriptGenerator: Generator = ({ model, output, outputMode, emit }) => { if (outputMode === 'split') { - const { dir, stem } = anchor(outputPath); + const { dir, stem } = output; const { entry, schemas } = emitClientSplit(model, emit, stem); return [ ...(schemas === undefined ? [] : [{ path: join(dir, `${stem}.schemas.ts`), content: schemas }]), - { path: outputPath, content: entry }, + { path: output.path, content: entry }, ]; } - return [{ path: outputPath, content: emitClientSingleFile(model, emit) }]; + return [{ path: output.path, content: emitClientSingleFile(model, emit) }]; }; /** @@ -35,9 +34,9 @@ export const typescriptGenerator: Generator = ({ model, outputPath, outputMode, * `typescriptSample` below, so the page shows the calling convention this run generated — * `argsStyle` included. */ -export const typescriptDocs: Generator = ({ model, outputPath, emit, pagination }) => [ +export const typescriptDocs: Generator = ({ model, output, emit, pagination }) => [ { - path: outputPath.replace(/\.[^.\\/]+$/, '.typescript.md'), + path: output.path.replace(/\.[^.\\/]+$/, '.typescript.md'), content: renderReferencePage(model, { title: `${model.title} TypeScript client reference`, frontmatter: emit.docsFrontmatter === true, @@ -47,7 +46,7 @@ export const typescriptDocs: Generator = ({ model, outputPath, emit, pagination fence: 'typescript', requires: 'The client has no dependencies.', }, - sample: (op) => typescriptSample(op, { model, emit, outputPath }), + sample: (op) => typescriptSample(op, { model, emit, outputPath: output.path }), paginated: new Set(pagination?.keys() ?? []), }), }, diff --git a/packages/client-generator/src/generators/zod/index.ts b/packages/client-generator/src/generators/zod/index.ts index 981dba25bb..53f9a27e81 100644 --- a/packages/client-generator/src/generators/zod/index.ts +++ b/packages/client-generator/src/generators/zod/index.ts @@ -1,8 +1,6 @@ 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'; /** @@ -16,9 +14,9 @@ import type { Generator } from '../types.js'; * how the sdk partitions its files. Emits nothing when the model has neither * named schemas nor JSON operation bodies. */ -export const zodGenerator: Generator = ({ model, outputPath }) => { +export const zodGenerator: Generator = ({ model, output, banner }) => { const content = renderZodModule(model); if (content === '') return []; - const { dir, stem } = anchor(outputPath); - return [{ path: join(dir, `${stem}.zod.ts`), content: `${HEADER}\n\n${content}` }]; + const header = banner.map((line) => `// ${line}`).join('\n'); + return [{ path: join(output.dir, `${output.stem}.zod.ts`), content: `${header}\n\n${content}` }]; }; diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index 9c48a2e1f8..d280a53e56 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -8,7 +8,7 @@ import { logger, stringifyYaml } from '@redocly/openapi-core'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { dirname, resolve, sep } from 'node:path'; +import { dirname, parse, resolve, sep } from 'node:path'; import type { EmitOptions } from './emitters/emit-options.js'; import { resolveModelPagination, type ModelPagination } from './emitters/pagination.js'; @@ -33,6 +33,13 @@ import type { GenerateClientOptions, GenerateClientResult } from './types.js'; * their files. Throws on a duplicate output path so two generators can't * silently clobber each other. Validation is the caller's job (`validateSelection`). */ +// The generated-by banner, comment-marker-free — each generator prepends it in its own +// comment syntax (the TypeScript family's HEADER renders these same lines with `//`). +const BANNER_LINES = [ + 'Generated by @redocly/client-generator — do not edit by hand.', + 'Source: OpenAPI description. Re-run `redocly generate-client` to update.', +]; + export function runGenerators( model: ApiModel, options: { @@ -52,12 +59,16 @@ export function runGenerators( // Every emitted path must stay under the --output directory: generator modules are // user-chosen code, but a stray `../` or absolute path must not write elsewhere. const outputRoot = resolve(dirname(options.outputPath)); + const { dir, name: stem, ext } = parse(options.outputPath); + const output = { path: options.outputPath, dir, stem, ext }; + const banner = BANNER_LINES; let documented = false; for (const name of options.generators) { const generator = options.registry.get(name)!; const input = { model, - outputPath: options.outputPath, + output, + banner, outputMode: options.outputMode, emit: options.emit, pagination: options.pagination, diff --git a/packages/client-generator/src/plugin.ts b/packages/client-generator/src/plugin.ts index 9b45e84a27..1e0095d011 100644 --- a/packages/client-generator/src/plugin.ts +++ b/packages/client-generator/src/plugin.ts @@ -18,10 +18,10 @@ // export default defineGenerator({ // name: 'route-map', // requires: ['typescript'], -// run({ model, outputPath }) { +// run({ model, output }) { // const routes = model.services.flatMap((s) => s.operations) // .map((op) => ` ${op.name}: '${op.method.toUpperCase()} ${op.path}',`).join('\n'); -// return [{ path: outputPath.replace(/\.ts$/, '.routes.ts'), +// return [{ path: output.path.replace(/\.ts$/, '.routes.ts'), // content: `export const routes = {\n${routes}\n} as const;\n` }]; // }, // }); 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 ae5109a626..d04cc39bc9 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 @@ -7,19 +7,19 @@ // TypeScript you would write: // // import { defineGenerator } from '@redocly/client-generator'; -// export default defineGenerator({ name: 'route-map', requires: ['typescript'], run({ model, outputPath }) { … } }); +// export default defineGenerator({ name: 'route-map', requires: ['typescript'], run({ model, output }) { … } }); // // `defineGenerator` is just an identity helper for types, so a plain object works too: export default { name: 'route-map', requires: ['typescript'], - run({ model, outputPath }) { + run({ model, output }) { const entries = model.services .flatMap((service) => service.operations) .map((op) => ` ${op.name}: '${op.method.toUpperCase()} ${op.path}',`); return [ { - path: outputPath.replace(/\.ts$/, '.routes.ts'), + path: output.path.replace(/\.ts$/, '.routes.ts'), content: '// Generated by the route-map custom generator. Do not edit by hand.\n' + `export const routes = {\n${entries.join('\n')}\n} as const;\n`, 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 index 1b39b8489c..eec5673b4f 100644 --- 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 @@ -20,8 +20,8 @@ client: /** @type {import('@redocly/client-generator').CustomGenerator} */ export default { name: 'my-generator', - run({ model, outputPath, outputMode, emit }) { - return [{ path: outputPath.replace(/\.ts$/, '.mine.txt'), content: '…' }]; + run({ model, output, outputMode, emit }) { + return [{ path: output.path.replace(/\.ts$/, '.mine.txt'), content: '…' }]; }, // Optional: one idiomatic call snippet per operation for docs (x-codeSamples), // collected into an overlay file when `client.codeSamples: true` is set. @@ -31,8 +31,8 @@ export default { // Optional: the reference page for what `run` emits, written when `client.docs` (or // --docs) is on. Same `{ path, content }` shape as `run`; `renderReferencePage` gives // the standard layout and takes `sample` for its snippets. A generator documents itself. - docs({ model, outputPath, emit }) { - return [{ path: outputPath.replace(/\.ts$/, '.mine.md'), content: '…' }]; + docs({ model, output, emit }) { + return [{ path: output.path.replace(/\.ts$/, '.mine.md'), content: '…' }]; }, }; ``` @@ -50,9 +50,9 @@ export default { properties: { groupBy: { enum: ['tag', 'path'], default: 'tag' } }, additionalProperties: false, }, - run({ model, outputPath, options }) { + run({ model, output, options }) { return [ - { path: outputPath.replace(/\.ts$/, '.permissions.md'), content: render(options.groupBy) }, + { path: output.path.replace(/\.ts$/, '.permissions.md'), content: render(options.groupBy) }, ]; }, }; diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/php.mjs b/tests/e2e/generate-client/examples/ejected-generator/generators/php.mjs index 608f9abdec..06b4262afe 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/generators/php.mjs +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/php.mjs @@ -484,7 +484,7 @@ function stripPhpHeader(source) { return lines.slice(start).join('\n').trim(); } /** The whole generated file: namespace + models + embedded runtime + operations + Client. */ -export const phpGenerator = ({ model, outputPath, emit }) => { +export const phpGenerator = ({ model, output, emit }) => { const writer = new Printer(' '); const namespace = identifierFor(model.title, { style: 'pascal', reserved: PHP }); writer.line(' { writePhpPaginationWrappers(writer, op, model, pageHydration, itemHydration, rule.items); } }, '}'); - return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.php'), content: writer.toString() }]; + return [{ path: output.path.replace(/\.[^.\\/]+$/, '.php'), content: writer.toString() }]; }; /** One idiomatic PHP call per operation — feeds `x-codeSamples` for docs. */ export function phpSample(op, ctx) { 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 e5d0073d47..9c7c69f16f 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 @@ -4,7 +4,7 @@ // // Authored in TypeScript you would write: // import { defineGenerator } from '@redocly/client-generator'; -// export default defineGenerator({ name: 'nested-facade', requires: ['typescript'], run({ model, outputPath }) { … } }); +// export default defineGenerator({ name: 'nested-facade', requires: ['typescript'], run({ model, output }) { … } }); 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}`; @@ -13,20 +13,20 @@ const groupIdent = (tag) => { export default { name: 'nested-facade', requires: ['typescript'], - run({ model, outputPath }) { + run({ model, output }) { const groups = new Map(); for (const op of model.services.flatMap((service) => service.operations)) { const group = groupIdent(op.tags[0] ?? 'other'); (groups.get(group) ?? groups.set(group, []).get(group)).push(op.name); } const names = [...groups.values()].flat().sort(); - const stem = outputPath.split(/[\\/]/).pop().replace(/\.ts$/, ''); + const stem = output.stem; const body = [...groups] .map(([group, ops]) => `export const ${group} = { ${ops.join(', ')} } as const;`) .join('\n'); return [ { - path: outputPath.replace(/\.ts$/, '.facade.ts'), + path: output.path.replace(/\.ts$/, '.facade.ts'), content: '// Generated by the nested-facade custom generator. Do not edit by hand.\n' + `import { ${names.join(', ')} } from './${stem}.js';\n\n` + diff --git a/tests/e2e/generate-client/examples/typescript-types-generator/response-map-generator.mjs b/tests/e2e/generate-client/examples/typescript-types-generator/response-map-generator.mjs index 0f5a8a34bd..651ac1b3ff 100644 --- a/tests/e2e/generate-client/examples/typescript-types-generator/response-map-generator.mjs +++ b/tests/e2e/generate-client/examples/typescript-types-generator/response-map-generator.mjs @@ -9,13 +9,13 @@ // // import { defineGenerator } from '@redocly/client-generator'; // import { tsType } from '@redocly/client-generator/generate'; -// export default defineGenerator({ name: 'response-map', requires: ['typescript'], run({ model, outputPath }) { … } }); +// export default defineGenerator({ name: 'response-map', requires: ['typescript'], run({ model, output }) { … } }); import { tsType } from '@redocly/client-generator/generate'; export default { name: 'response-map', requires: ['typescript'], - run({ model, outputPath }) { + run({ model, output }) { // Every operation with a JSON success body — a 204 or an image download has no entry. const withJsonBody = model.services .flatMap((service) => service.operations) @@ -40,7 +40,7 @@ export default { return [ { - path: outputPath.replace(/\.ts$/, '.responses.ts'), + path: output.path.replace(/\.ts$/, '.responses.ts'), content: '// Generated by the response-map custom generator. Do not edit by hand.\n' + importLine + 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 index 14f2c9f6ae..524e7fa136 100644 --- a/tests/e2e/generate-client/examples/valibot-generator/valibot-schema-generator.mjs +++ b/tests/e2e/generate-client/examples/valibot-generator/valibot-schema-generator.mjs @@ -8,7 +8,7 @@ // 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 }) { … } }); +// export default defineGenerator({ name: 'valibot', run({ model, output }) { … } }); // // `defineGenerator` only supplies types, so a plain object works the same. import { enumValues, flattenAllOf, Printer } from '@redocly/client-generator'; @@ -60,7 +60,7 @@ function valibotSchema(schema, model) { export default { name: 'valibot', - run({ model, outputPath }) { + run({ model, output }) { const printer = new Printer(); printer.line('// Generated by the valibot custom generator. Do not edit by hand.'); printer.line("import * as v from 'valibot';"); @@ -70,6 +70,6 @@ export default { printer.line(`export type ${name} = v.InferOutput;`); printer.blank(); } - return [{ path: outputPath.replace(/\.ts$/, '.valibot.ts'), content: printer.toString() }]; + return [{ path: output.path.replace(/\.ts$/, '.valibot.ts'), content: printer.toString() }]; }, }; diff --git a/tests/e2e/generate-client/fixtures/route-map-plugin.mjs b/tests/e2e/generate-client/fixtures/route-map-plugin.mjs index 8349875d80..54a43803e5 100644 --- a/tests/e2e/generate-client/fixtures/route-map-plugin.mjs +++ b/tests/e2e/generate-client/fixtures/route-map-plugin.mjs @@ -9,14 +9,14 @@ export default { properties: { exportName: { type: 'string', default: 'routes' } }, additionalProperties: false, }, - run({ model, outputPath, options }) { + run({ model, output, options }) { const routes = model.services .flatMap((s) => s.operations) .map((op) => ` ${op.name}: '${op.method.toUpperCase()} ${op.path}',`) .join('\n'); return [ { - path: outputPath.replace(/\.ts$/, '.routes.ts'), + path: output.path.replace(/\.ts$/, '.routes.ts'), content: `export const ${options.exportName} = {\n${routes}\n} as const;\n`, }, ]; From 44ba882b7b31f733b3c5d9fdc87e5a6c61336c23 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 21:41:39 +0300 Subject: [PATCH 18/35] refactor: move the satellite emitters (zod, mock, swr, tanstack-query, transformers, cli) into their generator folders --- packages/client-generator/src/generate.ts | 2 +- .../cli/__tests__/render.test.ts} | 8 ++++---- .../cli-docs.ts => generators/cli/docs.ts} | 4 ++-- .../client-generator/src/generators/cli/index.ts | 4 ++-- .../{emitters/cli.ts => generators/cli/render.ts} | 14 +++++++------- .../mock}/__tests__/faker.test.ts | 4 ++-- .../mock/__tests__/render.test.ts} | 4 ++-- .../mock}/__tests__/sample.test.ts | 2 +- .../src/{emitters => generators/mock}/faker.ts | 8 ++++---- .../client-generator/src/generators/mock/index.ts | 2 +- .../mock.ts => generators/mock/render.ts} | 14 +++++++------- .../src/{emitters => generators/mock}/sample.ts | 4 ++-- .../mock-value.ts => generators/mock/values.ts} | 4 ++-- .../swr/__tests__/render.test.ts} | 10 ++++++++-- .../client-generator/src/generators/swr/index.ts | 2 +- .../{emitters/swr.ts => generators/swr/render.ts} | 6 +++--- .../tanstack-query/__tests__/render.test.ts} | 12 +++++++++--- .../src/generators/tanstack-query/index.ts | 2 +- .../tanstack-query/render.ts} | 15 ++++++++++----- .../transformers/__tests__/render.test.ts} | 4 ++-- .../src/generators/transformers/index.ts | 2 +- .../transformers/render.ts} | 6 +++--- .../zod/__tests__/schemas.test.ts} | 6 +++--- .../client-generator/src/generators/zod/index.ts | 2 +- .../zod.ts => generators/zod/schemas.ts} | 8 ++++---- 25 files changed, 83 insertions(+), 66 deletions(-) rename packages/client-generator/src/{emitters/__tests__/cli.test.ts => generators/cli/__tests__/render.test.ts} (97%) rename packages/client-generator/src/{emitters/cli-docs.ts => generators/cli/docs.ts} (99%) rename packages/client-generator/src/{emitters/cli.ts => generators/cli/render.ts} (97%) rename packages/client-generator/src/{emitters => generators/mock}/__tests__/faker.test.ts (98%) rename packages/client-generator/src/{emitters/__tests__/mock.test.ts => generators/mock/__tests__/render.test.ts} (99%) rename packages/client-generator/src/{emitters => generators/mock}/__tests__/sample.test.ts (99%) rename packages/client-generator/src/{emitters => generators/mock}/faker.ts (97%) rename packages/client-generator/src/{emitters/mock.ts => generators/mock/render.ts} (97%) rename packages/client-generator/src/{emitters => generators/mock}/sample.ts (99%) rename packages/client-generator/src/{emitters/mock-value.ts => generators/mock/values.ts} (95%) rename packages/client-generator/src/{emitters/__tests__/swr.test.ts => generators/swr/__tests__/render.test.ts} (97%) rename packages/client-generator/src/{emitters/swr.ts => generators/swr/render.ts} (94%) rename packages/client-generator/src/{emitters/__tests__/tanstack-query.test.ts => generators/tanstack-query/__tests__/render.test.ts} (98%) rename packages/client-generator/src/{emitters/tanstack-query.ts => generators/tanstack-query/render.ts} (96%) rename packages/client-generator/src/{emitters/__tests__/transformers.test.ts => generators/transformers/__tests__/render.test.ts} (99%) rename packages/client-generator/src/{emitters/transformers.ts => generators/transformers/render.ts} (99%) rename packages/client-generator/src/{emitters/__tests__/zod.test.ts => generators/zod/__tests__/schemas.test.ts} (98%) rename packages/client-generator/src/{emitters/zod.ts => generators/zod/schemas.ts} (98%) diff --git a/packages/client-generator/src/generate.ts b/packages/client-generator/src/generate.ts index 5bcbf3aeb6..5147b9b905 100644 --- a/packages/client-generator/src/generate.ts +++ b/packages/client-generator/src/generate.ts @@ -52,4 +52,4 @@ export function collectGeneratedFiles( export { generateClient } from './pipeline.js'; // The composed-cli entry renderer: consumed by the redocly CLI across apis (it needs the // embedded runtime text, which must stay off the runtime-only root barrel). -export { renderComposedCliEntry, type ComposedCliSource } from './emitters/cli.js'; +export { renderComposedCliEntry, type ComposedCliSource } from './generators/cli/render.js'; diff --git a/packages/client-generator/src/emitters/__tests__/cli.test.ts b/packages/client-generator/src/generators/cli/__tests__/render.test.ts similarity index 97% rename from packages/client-generator/src/emitters/__tests__/cli.test.ts rename to packages/client-generator/src/generators/cli/__tests__/render.test.ts index 76bfb7ccba..f9b611aa7c 100644 --- a/packages/client-generator/src/emitters/__tests__/cli.test.ts +++ b/packages/client-generator/src/generators/cli/__tests__/render.test.ts @@ -1,8 +1,8 @@ import { logger } from '@redocly/openapi-core'; -import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; -import { commandData, renderCliModule, renderComposedCliEntry } from '../cli.js'; -import { resolveModelPagination } from '../pagination.js'; +import { resolveModelPagination } from '../../../emitters/pagination.js'; +import type { ApiModel, SchemaModel } from '../../../intermediate-representation/model.js'; +import { commandData, renderCliModule, renderComposedCliEntry } from '../render.js'; const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' }; const INT: SchemaModel = { kind: 'scalar', scalar: 'integer' }; @@ -307,7 +307,7 @@ describe('the package-mode import line', () => { .split(',') .map((specifier) => specifier.trim()) .filter((specifier) => specifier !== '' && !specifier.startsWith('type ')); - const root = (await import('../../index.js')) as Record; + 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'); } diff --git a/packages/client-generator/src/emitters/cli-docs.ts b/packages/client-generator/src/generators/cli/docs.ts similarity index 99% rename from packages/client-generator/src/emitters/cli-docs.ts rename to packages/client-generator/src/generators/cli/docs.ts index 5fbe7f8850..23c6a3ee2b 100644 --- a/packages/client-generator/src/emitters/cli-docs.ts +++ b/packages/client-generator/src/generators/cli/docs.ts @@ -3,8 +3,8 @@ // runtime addresses groups and reads credentials with. A second model would drift from // the tool the first time either side changed. -import { Printer } from '../authoring/printer.js'; -import { constantCase, groupSlug, type CliCommand, type CliFlag } from '../runtime/cli.js'; +import { Printer } from '../../authoring/printer.js'; +import { constantCase, groupSlug, type CliCommand, type CliFlag } from '../../runtime/cli.js'; export type CliDocsOptions = { /** Page heading. */ diff --git a/packages/client-generator/src/generators/cli/index.ts b/packages/client-generator/src/generators/cli/index.ts index ec543d881d..069719f650 100644 --- a/packages/client-generator/src/generators/cli/index.ts +++ b/packages/client-generator/src/generators/cli/index.ts @@ -1,10 +1,10 @@ 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 type { CodeSample, Generator, SampleContext } from '../types.js'; +import { renderCliDocs } from './docs.js'; +import { cliAuthSchemes, commandData, renderCliModule } from './render.js'; /** * The cli generator: a bin-ready `.cli.ts` — a zero-dependency, typed diff --git a/packages/client-generator/src/emitters/cli.ts b/packages/client-generator/src/generators/cli/render.ts similarity index 97% rename from packages/client-generator/src/emitters/cli.ts rename to packages/client-generator/src/generators/cli/render.ts index 3f1c61908c..57aed22df0 100644 --- a/packages/client-generator/src/emitters/cli.ts +++ b/packages/client-generator/src/generators/cli/render.ts @@ -4,24 +4,24 @@ import { logger } from '@redocly/openapi-core'; -import { casing } from '../authoring/naming.js'; +import { casing } from '../../authoring/naming.js'; +import { HEADER } from '../../emitters/emit-options.js'; +import { embedCliRuntime } from '../../emitters/inline-runtime.js'; +import type { ModelPagination } from '../../emitters/pagination.js'; +import { flatInputShape } from '../../emitters/render-client.js'; import type { ApiModel, OperationModel, ParamModel, SchemaModel, -} from '../intermediate-representation/model.js'; +} 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 type { ModelPagination } from './pagination.js'; -import { flatInputShape } from './render-client.js'; +} from '../../runtime/cli.js'; function kebab(name: string): string { return casing.snake(name).replace(/_/g, '-'); diff --git a/packages/client-generator/src/emitters/__tests__/faker.test.ts b/packages/client-generator/src/generators/mock/__tests__/faker.test.ts similarity index 98% rename from packages/client-generator/src/emitters/__tests__/faker.test.ts rename to packages/client-generator/src/generators/mock/__tests__/faker.test.ts index ea5a167fe3..4add9382bb 100644 --- a/packages/client-generator/src/emitters/__tests__/faker.test.ts +++ b/packages/client-generator/src/generators/mock/__tests__/faker.test.ts @@ -1,6 +1,6 @@ -import type { NamedSchemaModel, SchemaModel } from '../../intermediate-representation/model.js'; +import type { NamedSchemaModel, SchemaModel } from '../../../intermediate-representation/model.js'; import { fakerExpression } from '../faker.js'; -import { renderMockValue } from '../mock-value.js'; +import { renderMockValue } from '../values.js'; /** Emit `schema`'s faker expression and render it to source for substring assertions. */ function emit( diff --git a/packages/client-generator/src/emitters/__tests__/mock.test.ts b/packages/client-generator/src/generators/mock/__tests__/render.test.ts similarity index 99% rename from packages/client-generator/src/emitters/__tests__/mock.test.ts rename to packages/client-generator/src/generators/mock/__tests__/render.test.ts index d653803eb9..7e08cc19e9 100644 --- a/packages/client-generator/src/emitters/__tests__/mock.test.ts +++ b/packages/client-generator/src/generators/mock/__tests__/render.test.ts @@ -1,5 +1,5 @@ -import { renderMockModule } from '../mock.js'; -import { apiModel, namedSchema, operation, param } from './fixtures.js'; +import { apiModel, namedSchema, operation, param } from '../../../emitters/__tests__/fixtures.js'; +import { renderMockModule } from '../render.js'; describe('renderMockModule', () => { it('emits the msw import, a factory per named schema, and a handlers array', () => { diff --git a/packages/client-generator/src/emitters/__tests__/sample.test.ts b/packages/client-generator/src/generators/mock/__tests__/sample.test.ts similarity index 99% rename from packages/client-generator/src/emitters/__tests__/sample.test.ts rename to packages/client-generator/src/generators/mock/__tests__/sample.test.ts index 92e5b8f194..c7a121763c 100644 --- a/packages/client-generator/src/emitters/__tests__/sample.test.ts +++ b/packages/client-generator/src/generators/mock/__tests__/sample.test.ts @@ -1,4 +1,4 @@ -import type { NamedSchemaModel, SchemaModel } from '../../intermediate-representation/model.js'; +import type { NamedSchemaModel, SchemaModel } from '../../../intermediate-representation/model.js'; import { sampleValue, SampleExpression } from '../sample.js'; describe('sampleValue', () => { diff --git a/packages/client-generator/src/emitters/faker.ts b/packages/client-generator/src/generators/mock/faker.ts similarity index 97% rename from packages/client-generator/src/emitters/faker.ts rename to packages/client-generator/src/generators/mock/faker.ts index 0145663807..b23538d280 100644 --- a/packages/client-generator/src/emitters/faker.ts +++ b/packages/client-generator/src/generators/mock/faker.ts @@ -9,16 +9,16 @@ // `mockData` without touching call sites; `@faker-js/faker` becomes their // dev-dep while the real client stays dependency-free. +import { codeLiteral } from '../../emitters/ts-literal.js'; +import type { DateType } from '../../emitters/types.js'; import type { NamedSchemaModel, ScalarKind, SchemaMetadata, SchemaModel, -} from '../intermediate-representation/model.js'; -import { expr, isObjectValue, type MockEntry, type MockValue, objectValue } from './mock-value.js'; +} from '../../intermediate-representation/model.js'; import { splitIntersection } from './sample.js'; -import { codeLiteral } from './ts-literal.js'; -import type { DateType } from './types.js'; +import { expr, isObjectValue, type MockEntry, type MockValue, objectValue } from './values.js'; /** 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 diff --git a/packages/client-generator/src/generators/mock/index.ts b/packages/client-generator/src/generators/mock/index.ts index 01f7e92ceb..e9cad98fbf 100644 --- a/packages/client-generator/src/generators/mock/index.ts +++ b/packages/client-generator/src/generators/mock/index.ts @@ -1,7 +1,7 @@ import { join } from 'node:path'; -import { renderMockModule } from '../../emitters/mock.js'; import type { Generator } from '../types.js'; +import { renderMockModule } from './render.js'; /** * The mock generator: a standalone `.mocks.ts` module of MSW handlers and diff --git a/packages/client-generator/src/emitters/mock.ts b/packages/client-generator/src/generators/mock/render.ts similarity index 97% rename from packages/client-generator/src/emitters/mock.ts rename to packages/client-generator/src/generators/mock/render.ts index cb9f6c8770..5d29d71d91 100644 --- a/packages/client-generator/src/emitters/mock.ts +++ b/packages/client-generator/src/generators/mock/render.ts @@ -7,6 +7,10 @@ import { isPlainObject } from '@redocly/openapi-core'; +import { isIdentifier } from '../../emitters/identifier.js'; +import { pascalCase } from '../../emitters/support.js'; +import { codeLiteral } from '../../emitters/ts-literal.js'; +import type { DateType } from '../../emitters/types.js'; import { allOperations, type ApiModel, @@ -14,9 +18,9 @@ import { type OperationModel, type ResponseBodyModel, type SchemaModel, -} from '../intermediate-representation/model.js'; +} from '../../intermediate-representation/model.js'; import { fakerExpression } from './faker.js'; -import { isIdentifier } from './identifier.js'; +import { sampleValue, SampleExpression } from './sample.js'; import { expr, isObjectValue, @@ -24,11 +28,7 @@ import { objectValue, renderMockValue, spreadInto, -} from './mock-value.js'; -import { sampleValue, SampleExpression } from './sample.js'; -import { pascalCase } from './support.js'; -import { codeLiteral } from './ts-literal.js'; -import type { DateType } from './types.js'; +} from './values.js'; const INDENT = ' '; diff --git a/packages/client-generator/src/emitters/sample.ts b/packages/client-generator/src/generators/mock/sample.ts similarity index 99% rename from packages/client-generator/src/emitters/sample.ts rename to packages/client-generator/src/generators/mock/sample.ts index fbd74b81d2..9e909a3ae7 100644 --- a/packages/client-generator/src/emitters/sample.ts +++ b/packages/client-generator/src/generators/mock/sample.ts @@ -1,12 +1,12 @@ import { isPlainObject } from '@redocly/openapi-core'; +import type { DateType } from '../../emitters/types.js'; import type { NamedSchemaModel, ScalarKind, SchemaMetadata, SchemaModel, -} from '../intermediate-representation/model.js'; -import type { DateType } from './types.js'; +} from '../../intermediate-representation/model.js'; /** A sampled value the emitter must print as a raw TS expression rather than a JSON * literal — e.g. a `format: binary` field, whose generated type is `Blob`. The `code` diff --git a/packages/client-generator/src/emitters/mock-value.ts b/packages/client-generator/src/generators/mock/values.ts similarity index 95% rename from packages/client-generator/src/emitters/mock-value.ts rename to packages/client-generator/src/generators/mock/values.ts index b8412c17c2..bd557fc616 100644 --- a/packages/client-generator/src/emitters/mock-value.ts +++ b/packages/client-generator/src/generators/mock/values.ts @@ -2,8 +2,8 @@ // (for intersection merging and `...overrides` spreading) until the final render, // where indentation is threaded. Deliberately tiny. -import { safeIdent } from './identifier.js'; -import { sanitizeCodeString } from './ts-literal.js'; +import { safeIdent } from '../../emitters/identifier.js'; +import { sanitizeCodeString } from '../../emitters/ts-literal.js'; export type MockEntry = { key: string; value: MockValue } | { spread: string }; diff --git a/packages/client-generator/src/emitters/__tests__/swr.test.ts b/packages/client-generator/src/generators/swr/__tests__/render.test.ts similarity index 97% rename from packages/client-generator/src/emitters/__tests__/swr.test.ts rename to packages/client-generator/src/generators/swr/__tests__/render.test.ts index 7c9fac8770..e9edc79cf1 100644 --- a/packages/client-generator/src/emitters/__tests__/swr.test.ts +++ b/packages/client-generator/src/generators/swr/__tests__/render.test.ts @@ -1,5 +1,11 @@ -import { renderSwrModule } from '../swr.js'; -import { apiModel, namedSchema, operation, param, SCALAR } from './fixtures.js'; +import { + apiModel, + namedSchema, + operation, + param, + SCALAR, +} from '../../../emitters/__tests__/fixtures.js'; +import { renderSwrModule } from '../render.js'; const SDK = './client.js'; diff --git a/packages/client-generator/src/generators/swr/index.ts b/packages/client-generator/src/generators/swr/index.ts index 49b41b8614..eb547c84f5 100644 --- a/packages/client-generator/src/generators/swr/index.ts +++ b/packages/client-generator/src/generators/swr/index.ts @@ -1,7 +1,7 @@ import { join } from 'node:path'; -import { renderSwrModule } from '../../emitters/swr.js'; import type { Generator } from '../types.js'; +import { renderSwrModule } from './render.js'; /** * The swr generator: a standalone `.swr.ts` module of SWR hooks wrapping the diff --git a/packages/client-generator/src/emitters/swr.ts b/packages/client-generator/src/generators/swr/render.ts similarity index 94% rename from packages/client-generator/src/emitters/swr.ts rename to packages/client-generator/src/generators/swr/render.ts index 4a218db0fc..0e39dc7530 100644 --- a/packages/client-generator/src/emitters/swr.ts +++ b/packages/client-generator/src/generators/swr/render.ts @@ -8,8 +8,7 @@ // `swr`/`swr/mutation` are the consumer's peer; the sdk stays dependency-free. // Source-text templates throughout. -import type { ApiModel, OperationModel } from '../intermediate-representation/model.js'; -import { pascalCase } from './support.js'; +import { pascalCase } from '../../emitters/support.js'; import { hasInputs, isQuery, @@ -17,7 +16,8 @@ import { sdkNamedImportText, variablesName, wrappableOperations, -} from './wrapper-support.js'; +} from '../../emitters/wrapper-support.js'; +import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; export type SwrOptions = { /** Import specifier for the sdk entry the operation functions/types live in. */ diff --git a/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts b/packages/client-generator/src/generators/tanstack-query/__tests__/render.test.ts similarity index 98% rename from packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts rename to packages/client-generator/src/generators/tanstack-query/__tests__/render.test.ts index bf48497c45..814a7df176 100644 --- a/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts +++ b/packages/client-generator/src/generators/tanstack-query/__tests__/render.test.ts @@ -1,6 +1,12 @@ -import { resolveModelPagination, type PaginationConfig } from '../pagination.js'; -import { renderTanstackModule } from '../tanstack-query.js'; -import { apiModel, namedSchema, operation, param, SCALAR } from './fixtures.js'; +import { + apiModel, + namedSchema, + operation, + param, + SCALAR, +} from '../../../emitters/__tests__/fixtures.js'; +import { resolveModelPagination, type PaginationConfig } from '../../../emitters/pagination.js'; +import { renderTanstackModule } from '../render.js'; const SDK = './client.js'; diff --git a/packages/client-generator/src/generators/tanstack-query/index.ts b/packages/client-generator/src/generators/tanstack-query/index.ts index 5d9553f75d..9d7b816d9b 100644 --- a/packages/client-generator/src/generators/tanstack-query/index.ts +++ b/packages/client-generator/src/generators/tanstack-query/index.ts @@ -1,7 +1,7 @@ import { join } from 'node:path'; -import { renderTanstackModule } from '../../emitters/tanstack-query.js'; import type { Generator } from '../types.js'; +import { renderTanstackModule } from './render.js'; /** * The tanstack-query generator: a standalone `.tanstack.ts` module of diff --git a/packages/client-generator/src/emitters/tanstack-query.ts b/packages/client-generator/src/generators/tanstack-query/render.ts similarity index 96% rename from packages/client-generator/src/emitters/tanstack-query.ts rename to packages/client-generator/src/generators/tanstack-query/render.ts index c9b50a08b9..3a89faa469 100644 --- a/packages/client-generator/src/emitters/tanstack-query.ts +++ b/packages/client-generator/src/generators/tanstack-query/render.ts @@ -14,11 +14,16 @@ // is generator-derived (sanitized operation names, JSON-pointer property chains built // here) — never raw spec text. -import type { ApiModel, OperationModel } from '../intermediate-representation/model.js'; -import type { PaginationSpec } from '../runtime/types.js'; -import { codeString, isSafeIdentifier, safeIdent } from './identifier.js'; -import { type ModelPagination, resolveSchemaPointer } from './pagination.js'; -import { hasInputs, isQuery, variablesName, wrappableOperations } from './wrapper-support.js'; +import { codeString, isSafeIdentifier, safeIdent } from '../../emitters/identifier.js'; +import { type ModelPagination, resolveSchemaPointer } from '../../emitters/pagination.js'; +import { + hasInputs, + isQuery, + variablesName, + wrappableOperations, +} from '../../emitters/wrapper-support.js'; +import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; +import type { PaginationSpec } from '../../runtime/types.js'; export type TanstackOptions = { /** Import specifier for the sdk entry the `client` instance and types live in. */ diff --git a/packages/client-generator/src/emitters/__tests__/transformers.test.ts b/packages/client-generator/src/generators/transformers/__tests__/render.test.ts similarity index 99% rename from packages/client-generator/src/emitters/__tests__/transformers.test.ts rename to packages/client-generator/src/generators/transformers/__tests__/render.test.ts index aa73d24880..57786f80ca 100644 --- a/packages/client-generator/src/emitters/__tests__/transformers.test.ts +++ b/packages/client-generator/src/generators/transformers/__tests__/render.test.ts @@ -2,8 +2,8 @@ import type { ApiModel, NamedSchemaModel, PropertyModel, -} from '../../intermediate-representation/model.js'; -import { renderTransformersModule } from '../transformers.js'; +} from '../../../intermediate-representation/model.js'; +import { renderTransformersModule } from '../render.js'; const base: Omit = { title: 'T', diff --git a/packages/client-generator/src/generators/transformers/index.ts b/packages/client-generator/src/generators/transformers/index.ts index 3ecbe46017..06755c8bca 100644 --- a/packages/client-generator/src/generators/transformers/index.ts +++ b/packages/client-generator/src/generators/transformers/index.ts @@ -1,7 +1,7 @@ import { join } from 'node:path'; -import { renderTransformersModule } from '../../emitters/transformers.js'; import type { Generator } from '../types.js'; +import { renderTransformersModule } from './render.js'; /** * The transformers generator: a standalone `.transformers.ts` module of diff --git a/packages/client-generator/src/emitters/transformers.ts b/packages/client-generator/src/generators/transformers/render.ts similarity index 99% rename from packages/client-generator/src/emitters/transformers.ts rename to packages/client-generator/src/generators/transformers/render.ts index b913f91fcf..407fe7d950 100644 --- a/packages/client-generator/src/emitters/transformers.ts +++ b/packages/client-generator/src/generators/transformers/render.ts @@ -9,13 +9,13 @@ // `transformPet` calls `transformOwner(data["owner"])` when `Pet.owner` is an // `Owner` that has dates. Source-text templates throughout. +import { safeIdent } from '../../emitters/identifier.js'; +import { pascalCase } from '../../emitters/support.js'; import type { ApiModel, NamedSchemaModel, SchemaModel, -} from '../intermediate-representation/model.js'; -import { safeIdent } from './identifier.js'; -import { pascalCase } from './support.js'; +} from '../../intermediate-representation/model.js'; const INDENT = ' '; diff --git a/packages/client-generator/src/emitters/__tests__/zod.test.ts b/packages/client-generator/src/generators/zod/__tests__/schemas.test.ts similarity index 98% rename from packages/client-generator/src/emitters/__tests__/zod.test.ts rename to packages/client-generator/src/generators/zod/__tests__/schemas.test.ts index 82aecf3898..9b599a3a90 100644 --- a/packages/client-generator/src/emitters/__tests__/zod.test.ts +++ b/packages/client-generator/src/generators/zod/__tests__/schemas.test.ts @@ -1,6 +1,6 @@ -import type { NamedSchemaModel, SchemaModel } from '../../intermediate-representation/model.js'; -import { renderZodModule, schemaToZodExpression } from '../zod.js'; -import { apiModel, operation, response } from './fixtures.js'; +import { apiModel, operation, response } from '../../../emitters/__tests__/fixtures.js'; +import type { NamedSchemaModel, SchemaModel } from '../../../intermediate-representation/model.js'; +import { renderZodModule, schemaToZodExpression } from '../schemas.js'; /** Print a single expression by wrapping it in a throwaway const. */ function expr(schema: SchemaModel): string { diff --git a/packages/client-generator/src/generators/zod/index.ts b/packages/client-generator/src/generators/zod/index.ts index 53f9a27e81..ffd97ee299 100644 --- a/packages/client-generator/src/generators/zod/index.ts +++ b/packages/client-generator/src/generators/zod/index.ts @@ -1,7 +1,7 @@ import { join } from 'node:path'; -import { renderZodModule } from '../../emitters/zod.js'; import type { Generator } from '../types.js'; +import { renderZodModule } from './schemas.js'; /** * The zod generator: a standalone `.zod.ts` module of Zod schemas (one diff --git a/packages/client-generator/src/emitters/zod.ts b/packages/client-generator/src/generators/zod/schemas.ts similarity index 98% rename from packages/client-generator/src/emitters/zod.ts rename to packages/client-generator/src/generators/zod/schemas.ts index 4e52c9e1e2..0a1c43c6df 100644 --- a/packages/client-generator/src/emitters/zod.ts +++ b/packages/client-generator/src/generators/zod/schemas.ts @@ -9,6 +9,9 @@ // between major versions and are deferred. Refs become `z.lazy(() => …Schema)`, // which sidesteps declaration ordering and recursion uniformly. +import { safeIdent } from '../../emitters/identifier.js'; +import { pascalCase } from '../../emitters/support.js'; +import { codeLiteral } from '../../emitters/ts-literal.js'; import { allOperations, type ApiModel, @@ -16,10 +19,7 @@ import { type ScalarKind, type SchemaMetadata, type SchemaModel, -} from '../intermediate-representation/model.js'; -import { safeIdent } from './identifier.js'; -import { pascalCase } from './support.js'; -import { codeLiteral } from './ts-literal.js'; +} from '../../intermediate-representation/model.js'; const INDENT = ' '; From 844bd637b22966ecf5d0237a732343bef827dedf Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 21:49:08 +0300 Subject: [PATCH 19/35] refactor: move the option vocabulary (EmitOptions, ArgsStyle, ErrorMode, DateType) to the generator toolkit types --- .../__tests__/client-assembly.test.ts | 2 +- .../src/emitters/__tests__/descriptor.test.ts | 3 +- .../src/emitters/client-assembly.ts | 11 ++- .../src/emitters/descriptor.ts | 4 +- .../src/emitters/emit-options.ts | 73 ---------------- .../src/emitters/operations.ts | 35 -------- .../src/emitters/render-client.ts | 25 +++++- .../client-generator/src/emitters/ts-type.ts | 2 +- .../client-generator/src/emitters/types.ts | 5 -- packages/client-generator/src/generate.ts | 8 +- .../client-generator/src/generators/index.ts | 3 +- .../client-generator/src/generators/meta.ts | 3 +- .../src/generators/mock/faker.ts | 2 +- .../src/generators/mock/render.ts | 2 +- .../src/generators/mock/sample.ts | 2 +- .../client-generator/src/generators/types.ts | 86 ++++++++++++++++++- packages/client-generator/src/pipeline.ts | 2 +- packages/client-generator/src/plugin.ts | 3 +- packages/client-generator/src/types.ts | 3 +- 19 files changed, 131 insertions(+), 143 deletions(-) delete mode 100644 packages/client-generator/src/emitters/operations.ts delete mode 100644 packages/client-generator/src/emitters/types.ts 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 4cdfb011eb..cab282901c 100644 --- a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts +++ b/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts @@ -1,8 +1,8 @@ import ts from 'typescript'; +import type { EmitOptions } from '../../generators/types.js'; import type { ApiModel } from '../../intermediate-representation/model.js'; import { emitClientSingleFile } from '../client-assembly.js'; -import type { EmitOptions } from '../emit-options.js'; import { resolveModelPagination } from '../pagination.js'; import { modelWith, namedSchema, operation, param, response, SCALAR } from './fixtures.js'; diff --git a/packages/client-generator/src/emitters/__tests__/descriptor.test.ts b/packages/client-generator/src/emitters/__tests__/descriptor.test.ts index 9b0c5bd1c0..8700b8b4e2 100644 --- a/packages/client-generator/src/emitters/__tests__/descriptor.test.ts +++ b/packages/client-generator/src/emitters/__tests__/descriptor.test.ts @@ -4,9 +4,8 @@ import type { ResponseBodyModel, } from '../../intermediate-representation/model.js'; import { packageIdents, renderDescriptors } from '../descriptor.js'; -import type { EmitContext } from '../operations.js'; import type { ModelPagination } from '../pagination.js'; -import { renderOpsType } from '../render-client.js'; +import { type EmitContext, renderOpsType } from '../render-client.js'; import { apiModel, modelWith, operation, param } from './fixtures.js'; function emitDescriptors(model: ApiModel): string { diff --git a/packages/client-generator/src/emitters/client-assembly.ts b/packages/client-generator/src/emitters/client-assembly.ts index 22662c5811..add00d3c34 100644 --- a/packages/client-generator/src/emitters/client-assembly.ts +++ b/packages/client-generator/src/emitters/client-assembly.ts @@ -10,18 +10,23 @@ // a sibling `.schemas.ts` the entry re-exports (`emitClientSplit`). // Text templates throughout — no `typescript` at generate time. +import type { EmitOptions } from '../generators/types.js'; import { allOperations, type ApiModel, type OperationModel, } from '../intermediate-representation/model.js'; import { packageIdents, renderDescriptors } from './descriptor.js'; -import { banner, type EmitOptions, HEADER, renderTitleComment } from './emit-options.js'; +import { banner, HEADER, renderTitleComment } from './emit-options.js'; import { codeString } from './identifier.js'; import { assembleInlineRuntime } from './inline-runtime.js'; import { isTypedMultipart } from './operation-types.js'; -import type { EmitContext } from './operations.js'; -import { collectEntrySchemaRefs, renderAliases, renderOpsType } from './render-client.js'; +import { + collectEntrySchemaRefs, + type EmitContext, + renderAliases, + renderOpsType, +} from './render-client.js'; import { renderTypeAliases } from './ts-type.js'; import { renderTypeGuards } from './type-guards.js'; diff --git a/packages/client-generator/src/emitters/descriptor.ts b/packages/client-generator/src/emitters/descriptor.ts index d26b546a4c..7aab99b374 100644 --- a/packages/client-generator/src/emitters/descriptor.ts +++ b/packages/client-generator/src/emitters/descriptor.ts @@ -4,6 +4,8 @@ // guard against the runtime contract in src/runtime/types.ts). Text templates. import { securityRequirements } from '../authoring/operation.js'; +import type { DateType } from '../authoring/options.js'; +import type { ArgsStyle } from '../generators/types.js'; import { allOperations, type ApiModel, @@ -13,14 +15,12 @@ import { } from '../intermediate-representation/model.js'; import { uniqueIdent } from './identifier.js'; import { isTypedMultipart } from './operation-types.js'; -import type { ArgsStyle } from './operations.js'; import type { ModelPagination } from './pagination.js'; import { flatInputShape, responseText } from './render-client.js'; import { WIRING_NAMES } from './reserved-names.js'; import { responseHeaderSpecs } from './response-headers.js'; import { 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 diff --git a/packages/client-generator/src/emitters/emit-options.ts b/packages/client-generator/src/emitters/emit-options.ts index 9eb9560a7a..891a226b7c 100644 --- a/packages/client-generator/src/emitters/emit-options.ts +++ b/packages/client-generator/src/emitters/emit-options.ts @@ -1,84 +1,11 @@ import type { ApiModel } from '../intermediate-representation/model.js'; import { escapeJsDoc } from './jsdoc.js'; -import type { ArgsStyle } from './operations.js'; -import type { ModelPagination } from './pagination.js'; import { splitLines } from './support.js'; -import type { DateType } from './types.js'; - -// The public option vocabulary is re-exported from this module, so generators -// and the package barrel import the emitter surface from one place. -export type { ArgsStyle } from './operations.js'; /** The generated-by banner prepended to every emitted module. */ export const HEADER = `// Generated by @redocly/client-generator — do not edit by hand. // Source: OpenAPI description. Re-run \`redocly generate-client\` to update.`; -export type EmitOptions = { - /** - * Override the server URL baked into the generated client config. When omitted, - * the value is derived from `servers[0].url` in the source OpenAPI description. - */ - serverUrl?: string; - /** - * How operation inputs are passed to each call. Defaults to `'flat'`; - * `'grouped'` bundles inputs into a single `args` object. - */ - argsStyle?: ArgsStyle; - /** Error-handling shape of the generated client. Defaults to `'throw'`. */ - errorMode?: 'throw' | 'result'; - /** - * How `format: date-time`/`date` string fields are typed. `'string'` (default) - * keeps the ISO wire shape; `'Date'` emits a `Date` reference. Opt-in — pair with - * the `transformers` generator so the runtime value matches the type. - */ - dateType?: DateType; - /** - * How the `mock` generator produces data. `'static'` (default) inlines deterministic - * literals (zero-dep, contract-faithful); `'faker'` emits `@faker-js/faker` calls for - * realistic data — reproducible when `mockSeed` is set. Only the mock module is affected. - */ - mockData?: 'static' | 'faker'; - /** Seed for faker-mode mocks: emits a top-level `faker.seed()` so runs reproduce. */ - mockSeed?: number; - /** Leading element for every tanstack-query key — namespaces the cache when several - * generated APIs share one QueryClient (operationIds may collide across APIs). */ - queryKeyPrefix?: string; - /** - * A pre-baked publisher setup block (from `bakeSetup`) merged into the client's config - * via `mergeSetup`. Absent when no `--setup` is given. - */ - setup?: string; - /** Runtime distribution: 'inline' (default, self-contained) | 'package' (imports @redocly/client-generator). */ - runtime?: 'inline' | 'package'; - /** - * Extension used in generated relative import specifiers (the split entry's schemas - * re-export and each satellite's sdk import). `'js'` (default) is the tsc/bundler - * convention; `'ts'` targets runtimes that resolve specifiers literally, like Node's - * built-in type stripping (`node client.ts`). - */ - importExt?: 'js' | 'ts'; - /** - * Package clause of the `go` generator's output. Defaults to `client` — a generated - * file usually lands in a package the consumer already owns, so the name is theirs - * to choose. An invalid Go package name fails generation. - */ - goPackage?: string; - /** - * Auto-pagination RESOLVED by the pipeline (fit-verified, one answer per run), - * resolved together with each operation's `x-redoclyPagination` extension. Verified - * statically: an explicit rule that doesn't fit its operation fails generation. - */ - pagination?: ModelPagination; - /** - * Also write the reference documentation for what each selected generator emits: one - * Markdown page per generator that implements the `docs` hook. One switch for the whole - * run, so a new documented language never needs a new flag. - */ - docs?: boolean; - /** Emit YAML front matter carrying the title above each documentation page. */ - docsFrontmatter?: boolean; -}; - /** * Assemble file content from a header banner and a printed body: the leading * `// Generated by …` comment and the `/** title *​/` block are structural diff --git a/packages/client-generator/src/emitters/operations.ts b/packages/client-generator/src/emitters/operations.ts deleted file mode 100644 index 1e5c169a56..0000000000 --- a/packages/client-generator/src/emitters/operations.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { NamedSchemaModel } from '../intermediate-representation/model.js'; -import type { ModelPagination } from './pagination.js'; -import type { DateType } from './types.js'; - -/** Error-handling shape of the generated client: throw on non-2xx, or return a result union. */ -export type ErrorMode = 'throw' | 'result'; - -/** - * How an operation's inputs are passed to the generated call. - * - `'flat'` (default): path params spread as positional args, then the - * `params`/`body`/`headers` slots — one exported sugar arrow per operation. - * - `'grouped'`: the client methods' own shape — a single `args` object bundling - * every input; the sugar is a plain destructure of the client. The per-call - * `init: RequestOptions` stays a separate trailing argument in both styles. - */ -export type ArgsStyle = 'flat' | 'grouped'; - -/** - * The emit configuration every operation shares. Bundling it into one value keeps - * it out of the positional parameter lists of the operation emitters (which would - * otherwise thread the same arguments through every layer, inviting transposition - * bugs). Per-call structural data (response type, ordered path params, …) stays an - * explicit argument; only this cross-cutting config travels as `ctx`. - */ -export type EmitContext = { - argsStyle: ArgsStyle; - errorMode: ErrorMode; - dateType: DateType; - /** Names of every exported schema, used for `*` alias collision suppression. */ - schemaNames: Set; - /** Named schemas — used to resolve `$ref` / `allOf` wrappers on response-header types. */ - schemas?: readonly NamedSchemaModel[]; - /** Resolved auto-pagination per operation name (absent ⇒ nothing paginates). */ - pagination?: ModelPagination; -}; diff --git a/packages/client-generator/src/emitters/render-client.ts b/packages/client-generator/src/emitters/render-client.ts index 212828bb3d..a1b985f075 100644 --- a/packages/client-generator/src/emitters/render-client.ts +++ b/packages/client-generator/src/emitters/render-client.ts @@ -1,7 +1,8 @@ +import type { DateType } from '../authoring/options.js'; +import type { ArgsStyle, ErrorMode } from '../generators/types.js'; // 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, @@ -15,11 +16,29 @@ import { 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 type { ModelPagination } from './pagination.js'; import { responseHeadersTypeText } from './response-headers.js'; import { pascalCase } from './support.js'; import { tsJsdoc, tsType } from './ts-type.js'; -import type { DateType } from './types.js'; + +/** + * The emit configuration every operation shares. Bundling it into one value keeps + * it out of the positional parameter lists of the operation emitters (which would + * otherwise thread the same arguments through every layer, inviting transposition + * bugs). Per-call structural data (response type, ordered path params, …) stays an + * explicit argument; only this cross-cutting config travels as `ctx`. + */ +export type EmitContext = { + argsStyle: ArgsStyle; + errorMode: ErrorMode; + dateType: DateType; + /** Names of every exported schema, used for `*` alias collision suppression. */ + schemaNames: Set; + /** Named schemas — used to resolve `$ref` / `allOf` wrappers on response-header types. */ + schemas?: readonly NamedSchemaModel[]; + /** Resolved auto-pagination per operation name (absent ⇒ nothing paginates). */ + pagination?: ModelPagination; +}; const INDENT = ' '; diff --git a/packages/client-generator/src/emitters/ts-type.ts b/packages/client-generator/src/emitters/ts-type.ts index 27f410fe2e..bce1cf24da 100644 --- a/packages/client-generator/src/emitters/ts-type.ts +++ b/packages/client-generator/src/emitters/ts-type.ts @@ -2,6 +2,7 @@ // `typescript` import. Formatting contract: 4-space indent, double-quoted // literals, compound members parenthesized inside unions/intersections/arrays. +import type { DateType } from '../authoring/options.js'; import type { NamedSchemaModel, PropertyModel, @@ -11,7 +12,6 @@ import type { } 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 = ' '; diff --git a/packages/client-generator/src/emitters/types.ts b/packages/client-generator/src/emitters/types.ts deleted file mode 100644 index 30bce7b741..0000000000 --- a/packages/client-generator/src/emitters/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -// The TS emitters' shared option types. `DateType` is a NEUTRAL option (every -// language honors it), so it is defined in the authoring toolkit and re-exported -// here for the emitters that have always imported it from this module. - -export type { DateType } from '../authoring/options.js'; diff --git a/packages/client-generator/src/generate.ts b/packages/client-generator/src/generate.ts index 5147b9b905..84ad6ff377 100644 --- a/packages/client-generator/src/generate.ts +++ b/packages/client-generator/src/generate.ts @@ -5,9 +5,13 @@ // root: package-mode clients import the root at app runtime, and the root reaches // the pipeline only through a dynamic import. -import type { EmitOptions } from './emitters/emit-options.js'; import { builtinGenerators, validateGenerators } from './generators/index.js'; -import type { GeneratedFile, GeneratorDescriptor, OutputMode } from './generators/types.js'; +import type { + EmitOptions, + GeneratedFile, + GeneratorDescriptor, + OutputMode, +} from './generators/types.js'; import type { ApiModel } from './intermediate-representation/model.js'; import { runGenerators } from './pipeline.js'; diff --git a/packages/client-generator/src/generators/index.ts b/packages/client-generator/src/generators/index.ts index c101c6fec7..9391930a3d 100644 --- a/packages/client-generator/src/generators/index.ts +++ b/packages/client-generator/src/generators/index.ts @@ -1,4 +1,3 @@ -import type { EmitOptions } from '../emitters/emit-options.js'; import { cliDocs, cliGenerator, cliSample } from './cli/index.js'; import { goDocs, goGenerator, goSample } from './go/index.js'; import { BUILTIN_META, validateSelection, type BuiltinMeta } from './meta.js'; @@ -8,7 +7,7 @@ import { pythonDocs, pythonGenerator, pythonSample } from './python/index.js'; import { swrGenerator } from './swr/index.js'; import { tanstackQueryGenerator } from './tanstack-query/index.js'; import { transformersGenerator } from './transformers/index.js'; -import type { GeneratorDescriptor, GeneratorName, OutputMode } from './types.js'; +import type { EmitOptions, GeneratorDescriptor, GeneratorName, OutputMode } from './types.js'; import { typescriptDocs, typescriptGenerator, typescriptSample } from './typescript/index.js'; import { zodGenerator } from './zod/index.js'; diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index 51bf2b328d..f8478474d6 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -5,9 +5,8 @@ import { logger } from '@redocly/openapi-core'; -import type { EmitOptions } from '../emitters/emit-options.js'; import { NotSupportedError } from '../errors.js'; -import type { GeneratorDescriptor, GeneratorName, OutputMode } from './types.js'; +import type { EmitOptions, GeneratorDescriptor, GeneratorName, OutputMode } from './types.js'; export type BuiltinMeta = Omit & { load: () => Promise>; diff --git a/packages/client-generator/src/generators/mock/faker.ts b/packages/client-generator/src/generators/mock/faker.ts index b23538d280..5ab07594bb 100644 --- a/packages/client-generator/src/generators/mock/faker.ts +++ b/packages/client-generator/src/generators/mock/faker.ts @@ -9,8 +9,8 @@ // `mockData` without touching call sites; `@faker-js/faker` becomes their // dev-dep while the real client stays dependency-free. +import type { DateType } from '../../authoring/options.js'; import { codeLiteral } from '../../emitters/ts-literal.js'; -import type { DateType } from '../../emitters/types.js'; import type { NamedSchemaModel, ScalarKind, diff --git a/packages/client-generator/src/generators/mock/render.ts b/packages/client-generator/src/generators/mock/render.ts index 5d29d71d91..988d6afb07 100644 --- a/packages/client-generator/src/generators/mock/render.ts +++ b/packages/client-generator/src/generators/mock/render.ts @@ -7,10 +7,10 @@ import { isPlainObject } from '@redocly/openapi-core'; +import type { DateType } from '../../authoring/options.js'; import { isIdentifier } from '../../emitters/identifier.js'; import { pascalCase } from '../../emitters/support.js'; import { codeLiteral } from '../../emitters/ts-literal.js'; -import type { DateType } from '../../emitters/types.js'; import { allOperations, type ApiModel, diff --git a/packages/client-generator/src/generators/mock/sample.ts b/packages/client-generator/src/generators/mock/sample.ts index 9e909a3ae7..9be73238b3 100644 --- a/packages/client-generator/src/generators/mock/sample.ts +++ b/packages/client-generator/src/generators/mock/sample.ts @@ -1,6 +1,6 @@ import { isPlainObject } from '@redocly/openapi-core'; -import type { DateType } from '../../emitters/types.js'; +import type { DateType } from '../../authoring/options.js'; import type { NamedSchemaModel, ScalarKind, diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index 1a465f02c7..5e9ec1f767 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -1,10 +1,88 @@ -import type { EmitOptions } from '../emitters/emit-options.js'; -import type { ErrorMode } from '../emitters/operations.js'; -// packages/client-generator/src/generators/types.ts +import type { DateType } from '../authoring/options.js'; import type { ModelPagination } from '../emitters/pagination.js'; -import type { DateType } from '../emitters/types.js'; import type { ApiModel, OperationModel } from '../intermediate-representation/model.js'; +export type { DateType } from '../authoring/options.js'; + +/** Error-handling shape of the generated client: throw on non-2xx, or return a result union. */ +export type ErrorMode = 'throw' | 'result'; + +/** + * How an operation's inputs are passed to the generated call. + * - `'flat'` (default): path params spread as positional args, then the + * `params`/`body`/`headers` slots — one exported sugar arrow per operation. + * - `'grouped'`: the client methods' own shape — a single `args` object bundling + * every input; the sugar is a plain destructure of the client. The per-call + * `init: RequestOptions` stays a separate trailing argument in both styles. + */ +export type ArgsStyle = 'flat' | 'grouped'; + +export type EmitOptions = { + /** + * Override the server URL baked into the generated client config. When omitted, + * the value is derived from `servers[0].url` in the source OpenAPI description. + */ + serverUrl?: string; + /** + * How operation inputs are passed to each call. Defaults to `'flat'`; + * `'grouped'` bundles inputs into a single `args` object. + */ + argsStyle?: ArgsStyle; + /** Error-handling shape of the generated client. Defaults to `'throw'`. */ + errorMode?: 'throw' | 'result'; + /** + * How `format: date-time`/`date` string fields are typed. `'string'` (default) + * keeps the ISO wire shape; `'Date'` emits a `Date` reference. Opt-in — pair with + * the `transformers` generator so the runtime value matches the type. + */ + dateType?: DateType; + /** + * How the `mock` generator produces data. `'static'` (default) inlines deterministic + * literals (zero-dep, contract-faithful); `'faker'` emits `@faker-js/faker` calls for + * realistic data — reproducible when `mockSeed` is set. Only the mock module is affected. + */ + mockData?: 'static' | 'faker'; + /** Seed for faker-mode mocks: emits a top-level `faker.seed()` so runs reproduce. */ + mockSeed?: number; + /** Leading element for every tanstack-query key — namespaces the cache when several + * generated APIs share one QueryClient (operationIds may collide across APIs). */ + queryKeyPrefix?: string; + /** + * A pre-baked publisher setup block (from `bakeSetup`) merged into the client's config + * via `mergeSetup`. Absent when no `--setup` is given. + */ + setup?: string; + /** Runtime distribution: 'inline' (default, self-contained) | 'package' (imports @redocly/client-generator). */ + runtime?: 'inline' | 'package'; + /** + * Extension used in generated relative import specifiers (the split entry's schemas + * re-export and each satellite's sdk import). `'js'` (default) is the tsc/bundler + * convention; `'ts'` targets runtimes that resolve specifiers literally, like Node's + * built-in type stripping (`node client.ts`). + */ + importExt?: 'js' | 'ts'; + /** + * Package clause of the `go` generator's output. Defaults to `client` — a generated + * file usually lands in a package the consumer already owns, so the name is theirs + * to choose. An invalid Go package name fails generation. + */ + goPackage?: string; + /** + * Auto-pagination RESOLVED by the pipeline (fit-verified, one answer per run), + * resolved together with each operation's `x-redoclyPagination` extension. Verified + * statically: an explicit rule that doesn't fit its operation fails generation. + */ + pagination?: ModelPagination; + /** + * Also write the reference documentation for what each selected generator emits: one + * Markdown page per generator that implements the `docs` hook. One switch for the whole + * run, so a new documented language never needs a new flag. + */ + docs?: boolean; + /** Emit YAML front matter carrying the title above each documentation page. */ + docsFrontmatter?: boolean; +}; + /** * How the generated client is partitioned across files. * diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index d280a53e56..ba54d5e7d9 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -10,7 +10,6 @@ import { logger, stringifyYaml } from '@redocly/openapi-core'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, parse, resolve, sep } from 'node:path'; -import type { EmitOptions } from './emitters/emit-options.js'; import { resolveModelPagination, type ModelPagination } from './emitters/pagination.js'; import { NotSupportedError } from './errors.js'; import { validateSelection } from './generators/meta.js'; @@ -18,6 +17,7 @@ import { resolveGeneratorOptions } from './generators/options.js'; import { resolveGenerators } from './generators/resolve.js'; import type { CodeSample, + EmitOptions, GeneratedFile, GeneratorDescriptor, OutputMode, diff --git a/packages/client-generator/src/plugin.ts b/packages/client-generator/src/plugin.ts index 1e0095d011..ddb4a42117 100644 --- a/packages/client-generator/src/plugin.ts +++ b/packages/client-generator/src/plugin.ts @@ -49,8 +49,7 @@ export type { GeneratorName, OutputMode, } from './generators/types.js'; -export type { ArgsStyle, ErrorMode } from './emitters/operations.js'; -export type { DateType } from './emitters/types.js'; +export type { ArgsStyle, DateType, ErrorMode } from './generators/types.js'; // --- The intermediate representation (the `model` a generator walks) --------------------------- export type { diff --git a/packages/client-generator/src/types.ts b/packages/client-generator/src/types.ts index 59ef57c853..cedbcecc07 100644 --- a/packages/client-generator/src/types.ts +++ b/packages/client-generator/src/types.ts @@ -1,8 +1,7 @@ import type { Config as RedoclyConfig, Oas3Definition, detectSpec } from '@redocly/openapi-core'; -import type { ArgsStyle } from './emitters/emit-options.js'; import type { PaginationConfig } from './emitters/pagination.js'; -import type { CustomGenerator, OutputMode } from './generators/types.js'; +import type { ArgsStyle, CustomGenerator, OutputMode } from './generators/types.js'; export type GenerateClientOptions = { /** Path or URL to the OpenAPI description (or an `apis:` alias from `redocly.yaml`). */ From 48771d883bfbaab9af5568c19447043ac51e5a26 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 21:55:37 +0300 Subject: [PATCH 20/35] refactor: move the TypeScript emitters into generators/typescript and publish the SDK ABI as contracts/typescript --- packages/client-generator/package.json | 5 ++++ .../__tests__/typescript.test.ts} | 4 ++-- .../typescript.ts} | 23 +++++++++++++------ .../src/emitters/__tests__/fixtures.ts | 2 +- packages/client-generator/src/generate.ts | 6 ++--- .../generators/__tests__/typescript.test.ts | 2 +- .../src/generators/cli/render.ts | 8 +++++-- .../src/generators/swr/render.ts | 4 ++-- .../src/generators/tanstack-query/render.ts | 6 ++--- .../client-assembly.test.ts.snap | 0 .../typescript/__tests__/banner.test.ts} | 4 ++-- .../__tests__/client-assembly.test.ts | 15 ++++++++---- .../typescript}/__tests__/descriptor.test.ts | 6 ++--- .../typescript}/__tests__/operations.test.ts | 14 +++++++++-- .../typescript}/__tests__/ts-type.test.ts | 2 +- .../typescript}/__tests__/type-guards.test.ts | 4 ++-- .../typescript/banner.ts} | 6 ++--- .../typescript}/client-assembly.ts | 10 ++++---- .../typescript}/descriptor.ts | 16 ++++++------- .../src/generators/typescript/index.ts | 4 ++-- .../typescript}/operation-signature.ts | 4 ++-- .../typescript}/operation-types.ts | 2 +- .../typescript}/render-client.ts | 12 +++++----- .../typescript}/response-headers.ts | 10 ++++---- .../typescript}/ts-type.ts | 8 +++---- .../typescript}/type-guards.ts | 2 +- 26 files changed, 107 insertions(+), 72 deletions(-) rename packages/client-generator/src/{emitters/__tests__/operation-signature.test.ts => contracts/__tests__/typescript.test.ts} (93%) rename packages/client-generator/src/{emitters/wrapper-support.ts => contracts/typescript.ts} (82%) rename packages/client-generator/src/{emitters => generators/typescript}/__tests__/__snapshots__/client-assembly.test.ts.snap (100%) rename packages/client-generator/src/{emitters/__tests__/emit-options.test.ts => generators/typescript/__tests__/banner.test.ts} (92%) rename packages/client-generator/src/{emitters => generators/typescript}/__tests__/client-assembly.test.ts (98%) rename packages/client-generator/src/{emitters => generators/typescript}/__tests__/descriptor.test.ts (99%) rename packages/client-generator/src/{emitters => generators/typescript}/__tests__/operations.test.ts (98%) rename packages/client-generator/src/{emitters => generators/typescript}/__tests__/ts-type.test.ts (97%) rename packages/client-generator/src/{emitters => generators/typescript}/__tests__/type-guards.test.ts (98%) rename packages/client-generator/src/{emitters/emit-options.ts => generators/typescript/banner.ts} (87%) rename packages/client-generator/src/{emitters => generators/typescript}/client-assembly.ts (97%) rename packages/client-generator/src/{emitters => generators/typescript}/descriptor.ts (91%) rename packages/client-generator/src/{emitters => generators/typescript}/operation-signature.ts (93%) rename packages/client-generator/src/{emitters => generators/typescript}/operation-types.ts (87%) rename packages/client-generator/src/{emitters => generators/typescript}/render-client.ts (98%) rename packages/client-generator/src/{emitters => generators/typescript}/response-headers.ts (87%) rename packages/client-generator/src/{emitters => generators/typescript}/ts-type.ts (95%) rename packages/client-generator/src/{emitters => generators/typescript}/type-guards.ts (99%) diff --git a/packages/client-generator/package.json b/packages/client-generator/package.json index b5069e3c36..a44dd930e3 100644 --- a/packages/client-generator/package.json +++ b/packages/client-generator/package.json @@ -41,6 +41,11 @@ "import": "./lib/printers/index.js", "default": "./lib/printers/index.js" }, + "./contracts/typescript": { + "types": "./lib/contracts/typescript.d.ts", + "import": "./lib/contracts/typescript.js", + "default": "./lib/contracts/typescript.js" + }, "./runtime-sources": { "types": "./lib/runtime-sources.d.ts", "import": "./lib/runtime-sources.js", diff --git a/packages/client-generator/src/emitters/__tests__/operation-signature.test.ts b/packages/client-generator/src/contracts/__tests__/typescript.test.ts similarity index 93% rename from packages/client-generator/src/emitters/__tests__/operation-signature.test.ts rename to packages/client-generator/src/contracts/__tests__/typescript.test.ts index de6528fc27..3485e7f956 100644 --- a/packages/client-generator/src/emitters/__tests__/operation-signature.test.ts +++ b/packages/client-generator/src/contracts/__tests__/typescript.test.ts @@ -1,5 +1,5 @@ -import { operationSignature, templatePathParams } from '../operation-signature.js'; -import { operation, param } from './fixtures.js'; +import { operation, param } from '../../emitters/__tests__/fixtures.js'; +import { operationSignature, templatePathParams } from '../typescript.js'; describe('operationSignature', () => { it('orders path params by URL-template position, keeping their wire names', () => { diff --git a/packages/client-generator/src/emitters/wrapper-support.ts b/packages/client-generator/src/contracts/typescript.ts similarity index 82% rename from packages/client-generator/src/emitters/wrapper-support.ts rename to packages/client-generator/src/contracts/typescript.ts index b880c7f66b..38bdfbca83 100644 --- a/packages/client-generator/src/emitters/wrapper-support.ts +++ b/packages/client-generator/src/contracts/typescript.ts @@ -1,14 +1,13 @@ -// Shared support for the data-fetching wrapper generators (`swr`, `tanstack-query`). -// Both wrap the sdk's exported operation functions, so they agree on which operations -// are wrappable and on the `vars`/`init` parameter shape. Keeping that agreement in one -// place stops the two emitters from drifting (and makes a third adapter cheap). The -// per-operation factory/hook bodies stay in each emitter — only the cross-cutting -// calling-convention pieces live here. +// The published output ABI of the `typescript` generator — what its emitted SDK +// exports and how a call is spelled. Importable ONLY along a declared `requires` +// edge (`swr`, `tanstack-query`, and `cli` require `typescript`): duplicating these +// answers in each wrapper would put the SDK's calling convention in several places, +// which is exactly the drift this module exists to prevent. import { logger } from '@redocly/openapi-core'; +import { operationSignature } from '../generators/typescript/operation-signature.js'; import type { ApiModel, OperationModel } from '../intermediate-representation/model.js'; -import { operationSignature } from './operation-signature.js'; /** * The operations a wrapper generator can wrap, with skips reported to the user under @@ -94,3 +93,13 @@ export function sdkNamedImportText( const specifiers = [...values, ...types.map((name) => `type ${name}`)].join(', '); return `import { ${specifiers} } from ${JSON.stringify(sdkModule)};`; } + +// The pieces the typescript generator decides itself, published for the generators +// that must agree with it: the per-operation signature facts and whether an +// operation's inputs merge into one flat object (`cli` renders the same call shape). +export { + operationSignature, + templatePathParams, + type OperationSignature, +} from '../generators/typescript/operation-signature.js'; +export { flatInputShape } from '../generators/typescript/render-client.js'; diff --git a/packages/client-generator/src/emitters/__tests__/fixtures.ts b/packages/client-generator/src/emitters/__tests__/fixtures.ts index f413e25179..555c4e7147 100644 --- a/packages/client-generator/src/emitters/__tests__/fixtures.ts +++ b/packages/client-generator/src/emitters/__tests__/fixtures.ts @@ -1,3 +1,4 @@ +import { emitClientSingleFile } from '../../generators/typescript/client-assembly.js'; import { sseFromResponses } from '../../intermediate-representation/build.js'; import type { ApiModel, @@ -7,7 +8,6 @@ import type { ResponseBodyModel, SchemaModel, } from '../../intermediate-representation/model.js'; -import { emitClientSingleFile } from '../client-assembly.js'; /** A plain `string` scalar — the default schema for params and the most-reused leaf. */ export const SCALAR: SchemaModel = { kind: 'scalar', scalar: 'string' }; diff --git a/packages/client-generator/src/generate.ts b/packages/client-generator/src/generate.ts index 84ad6ff377..6b035aa7e9 100644 --- a/packages/client-generator/src/generate.ts +++ b/packages/client-generator/src/generate.ts @@ -20,13 +20,13 @@ import { runGenerators } from './pipeline.js'; // when every built-in generator migrated to text (one authoring model for every // output language). `tsType`/`tsJsdoc`/`codeLiteral` are the TypeScript-specific // text renderers the sdk itself uses. -export { tsJsdoc, tsType } from './emitters/ts-type.js'; +export { tsJsdoc, tsType } from './generators/typescript/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 { operationSignature } from './contracts/typescript.js'; +export type { OperationSignature } from './contracts/typescript.js'; export { pascalCase } from './emitters/support.js'; export { safeIdent } from './emitters/identifier.js'; diff --git a/packages/client-generator/src/generators/__tests__/typescript.test.ts b/packages/client-generator/src/generators/__tests__/typescript.test.ts index 935cf8ab02..c72194dbe5 100644 --- a/packages/client-generator/src/generators/__tests__/typescript.test.ts +++ b/packages/client-generator/src/generators/__tests__/typescript.test.ts @@ -1,5 +1,5 @@ -import { HEADER } from '../../emitters/emit-options.js'; import type { ApiModel } from '../../intermediate-representation/model.js'; +import { HEADER } from '../typescript/banner.js'; import { typescriptGenerator as typescriptGeneratorEntry } from '../typescript/index.js'; import { generatorInput } from './fixtures/generator-input.js'; diff --git a/packages/client-generator/src/generators/cli/render.ts b/packages/client-generator/src/generators/cli/render.ts index 57aed22df0..3ac3b514bf 100644 --- a/packages/client-generator/src/generators/cli/render.ts +++ b/packages/client-generator/src/generators/cli/render.ts @@ -5,10 +5,9 @@ import { logger } from '@redocly/openapi-core'; import { casing } from '../../authoring/naming.js'; -import { HEADER } from '../../emitters/emit-options.js'; +import { flatInputShape } from '../../contracts/typescript.js'; import { embedCliRuntime } from '../../emitters/inline-runtime.js'; import type { ModelPagination } from '../../emitters/pagination.js'; -import { flatInputShape } from '../../emitters/render-client.js'; import type { ApiModel, OperationModel, @@ -23,6 +22,11 @@ import { type CliFlag, } from '../../runtime/cli.js'; +// The generated-by banner every emitted module carries (same lines as the pipeline's +// `input.banner`, rendered in `//` syntax). +const HEADER = `// Generated by @redocly/client-generator — do not edit by hand. +// Source: OpenAPI description. Re-run \`redocly generate-client\` to update.`; + function kebab(name: string): string { return casing.snake(name).replace(/_/g, '-'); } diff --git a/packages/client-generator/src/generators/swr/render.ts b/packages/client-generator/src/generators/swr/render.ts index 0e39dc7530..148e99a018 100644 --- a/packages/client-generator/src/generators/swr/render.ts +++ b/packages/client-generator/src/generators/swr/render.ts @@ -8,7 +8,6 @@ // `swr`/`swr/mutation` are the consumer's peer; the sdk stays dependency-free. // Source-text templates throughout. -import { pascalCase } from '../../emitters/support.js'; import { hasInputs, isQuery, @@ -16,7 +15,8 @@ import { sdkNamedImportText, variablesName, wrappableOperations, -} from '../../emitters/wrapper-support.js'; +} from '../../contracts/typescript.js'; +import { pascalCase } from '../../emitters/support.js'; import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; export type SwrOptions = { diff --git a/packages/client-generator/src/generators/tanstack-query/render.ts b/packages/client-generator/src/generators/tanstack-query/render.ts index 3a89faa469..98762ce753 100644 --- a/packages/client-generator/src/generators/tanstack-query/render.ts +++ b/packages/client-generator/src/generators/tanstack-query/render.ts @@ -14,14 +14,14 @@ // is generator-derived (sanitized operation names, JSON-pointer property chains built // here) — never raw spec text. -import { codeString, isSafeIdentifier, safeIdent } from '../../emitters/identifier.js'; -import { type ModelPagination, resolveSchemaPointer } from '../../emitters/pagination.js'; import { hasInputs, isQuery, variablesName, wrappableOperations, -} from '../../emitters/wrapper-support.js'; +} from '../../contracts/typescript.js'; +import { codeString, isSafeIdentifier, safeIdent } from '../../emitters/identifier.js'; +import { type ModelPagination, resolveSchemaPointer } from '../../emitters/pagination.js'; import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; import type { PaginationSpec } from '../../runtime/types.js'; diff --git a/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap b/packages/client-generator/src/generators/typescript/__tests__/__snapshots__/client-assembly.test.ts.snap similarity index 100% rename from packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap rename to packages/client-generator/src/generators/typescript/__tests__/__snapshots__/client-assembly.test.ts.snap diff --git a/packages/client-generator/src/emitters/__tests__/emit-options.test.ts b/packages/client-generator/src/generators/typescript/__tests__/banner.test.ts similarity index 92% rename from packages/client-generator/src/emitters/__tests__/emit-options.test.ts rename to packages/client-generator/src/generators/typescript/__tests__/banner.test.ts index 39291ca797..89b1fdca0b 100644 --- a/packages/client-generator/src/emitters/__tests__/emit-options.test.ts +++ b/packages/client-generator/src/generators/typescript/__tests__/banner.test.ts @@ -1,5 +1,5 @@ -import { banner, HEADER, renderTitleComment } from '../emit-options.js'; -import { apiModel } from './fixtures.js'; +import { apiModel } from '../../../emitters/__tests__/fixtures.js'; +import { banner, HEADER, renderTitleComment } from '../banner.js'; describe('banner', () => { it('joins non-empty sections with blank lines and appends a trailing newline', () => { diff --git a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts b/packages/client-generator/src/generators/typescript/__tests__/client-assembly.test.ts similarity index 98% rename from packages/client-generator/src/emitters/__tests__/client-assembly.test.ts rename to packages/client-generator/src/generators/typescript/__tests__/client-assembly.test.ts index cab282901c..b0c3a5abca 100644 --- a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts +++ b/packages/client-generator/src/generators/typescript/__tests__/client-assembly.test.ts @@ -1,10 +1,17 @@ import ts from 'typescript'; -import type { EmitOptions } from '../../generators/types.js'; -import type { ApiModel } from '../../intermediate-representation/model.js'; +import { + modelWith, + namedSchema, + operation, + param, + response, + SCALAR, +} from '../../../emitters/__tests__/fixtures.js'; +import { resolveModelPagination } from '../../../emitters/pagination.js'; +import type { ApiModel } from '../../../intermediate-representation/model.js'; +import type { EmitOptions } from '../../types.js'; import { emitClientSingleFile } from '../client-assembly.js'; -import { resolveModelPagination } from '../pagination.js'; -import { modelWith, namedSchema, operation, param, response, SCALAR } from './fixtures.js'; /** The package arm of the shared emitter. */ function emit(model: ApiModel, options: EmitOptions = {}): string { diff --git a/packages/client-generator/src/emitters/__tests__/descriptor.test.ts b/packages/client-generator/src/generators/typescript/__tests__/descriptor.test.ts similarity index 99% rename from packages/client-generator/src/emitters/__tests__/descriptor.test.ts rename to packages/client-generator/src/generators/typescript/__tests__/descriptor.test.ts index 8700b8b4e2..4ec0610583 100644 --- a/packages/client-generator/src/emitters/__tests__/descriptor.test.ts +++ b/packages/client-generator/src/generators/typescript/__tests__/descriptor.test.ts @@ -1,12 +1,12 @@ +import { apiModel, modelWith, operation, param } from '../../../emitters/__tests__/fixtures.js'; +import type { ModelPagination } from '../../../emitters/pagination.js'; import type { ApiModel, OperationModel, ResponseBodyModel, -} from '../../intermediate-representation/model.js'; +} from '../../../intermediate-representation/model.js'; import { packageIdents, renderDescriptors } from '../descriptor.js'; -import type { ModelPagination } from '../pagination.js'; import { type EmitContext, renderOpsType } from '../render-client.js'; -import { apiModel, modelWith, operation, param } from './fixtures.js'; function emitDescriptors(model: ApiModel): string { return renderDescriptors(model, packageIdents(model), 'string'); diff --git a/packages/client-generator/src/emitters/__tests__/operations.test.ts b/packages/client-generator/src/generators/typescript/__tests__/operations.test.ts similarity index 98% rename from packages/client-generator/src/emitters/__tests__/operations.test.ts rename to packages/client-generator/src/generators/typescript/__tests__/operations.test.ts index 6dd560bad8..eddafd5ed3 100644 --- a/packages/client-generator/src/emitters/__tests__/operations.test.ts +++ b/packages/client-generator/src/generators/typescript/__tests__/operations.test.ts @@ -1,9 +1,19 @@ +import { + SCALAR, + apiModel, + emitWithOp, + namedSchema, + operation, + param, +} from '../../../emitters/__tests__/fixtures.js'; // One operation's developer-facing surface in the descriptor-wired single-file client: // the input shape in both styles, and the `*` aliases. The wiring itself (Ops, // OPERATIONS, client, sugar) is covered in client-assembly.test.ts. -import type { OperationModel, RequestBodyModel } from '../../intermediate-representation/model.js'; +import type { + OperationModel, + RequestBodyModel, +} from '../../../intermediate-representation/model.js'; import { emitClientSingleFile } from '../client-assembly.js'; -import { SCALAR, apiModel, emitWithOp, namedSchema, operation, param } from './fixtures.js'; /** Emit a result-mode single-file client whose only operation is `operation(op)`. */ function emitResult(op: Partial, schemas: string[] = []): string { diff --git a/packages/client-generator/src/emitters/__tests__/ts-type.test.ts b/packages/client-generator/src/generators/typescript/__tests__/ts-type.test.ts similarity index 97% rename from packages/client-generator/src/emitters/__tests__/ts-type.test.ts rename to packages/client-generator/src/generators/typescript/__tests__/ts-type.test.ts index 12b48fecae..75d8ec3562 100644 --- a/packages/client-generator/src/emitters/__tests__/ts-type.test.ts +++ b/packages/client-generator/src/generators/typescript/__tests__/ts-type.test.ts @@ -1,4 +1,4 @@ -import type { NamedSchemaModel, SchemaModel } from '../../intermediate-representation/model.js'; +import type { NamedSchemaModel, SchemaModel } from '../../../intermediate-representation/model.js'; import { renderTypeAliases, tsType } from '../ts-type.js'; // Literal expectations for the TS type renderer — the formatting contract every diff --git a/packages/client-generator/src/emitters/__tests__/type-guards.test.ts b/packages/client-generator/src/generators/typescript/__tests__/type-guards.test.ts similarity index 98% rename from packages/client-generator/src/emitters/__tests__/type-guards.test.ts rename to packages/client-generator/src/generators/typescript/__tests__/type-guards.test.ts index 698d044d30..8c2d2d44ad 100644 --- a/packages/client-generator/src/emitters/__tests__/type-guards.test.ts +++ b/packages/client-generator/src/generators/typescript/__tests__/type-guards.test.ts @@ -1,6 +1,6 @@ -import type { NamedSchemaModel, SchemaModel } from '../../intermediate-representation/model.js'; +import { apiModel, namedSchema } from '../../../emitters/__tests__/fixtures.js'; +import type { NamedSchemaModel, SchemaModel } from '../../../intermediate-representation/model.js'; import { emitClientSingleFile } from '../client-assembly.js'; -import { apiModel, namedSchema } from './fixtures.js'; // The package arm keeps the emitted text free of the embedded runtime, so the // absence assertions below test the schema types/guards alone. diff --git a/packages/client-generator/src/emitters/emit-options.ts b/packages/client-generator/src/generators/typescript/banner.ts similarity index 87% rename from packages/client-generator/src/emitters/emit-options.ts rename to packages/client-generator/src/generators/typescript/banner.ts index 891a226b7c..dd17f052a6 100644 --- a/packages/client-generator/src/emitters/emit-options.ts +++ b/packages/client-generator/src/generators/typescript/banner.ts @@ -1,6 +1,6 @@ -import type { ApiModel } from '../intermediate-representation/model.js'; -import { escapeJsDoc } from './jsdoc.js'; -import { splitLines } from './support.js'; +import { escapeJsDoc } from '../../emitters/jsdoc.js'; +import { splitLines } from '../../emitters/support.js'; +import type { ApiModel } from '../../intermediate-representation/model.js'; /** The generated-by banner prepended to every emitted module. */ export const HEADER = `// Generated by @redocly/client-generator — do not edit by hand. diff --git a/packages/client-generator/src/emitters/client-assembly.ts b/packages/client-generator/src/generators/typescript/client-assembly.ts similarity index 97% rename from packages/client-generator/src/emitters/client-assembly.ts rename to packages/client-generator/src/generators/typescript/client-assembly.ts index add00d3c34..980036eb27 100644 --- a/packages/client-generator/src/emitters/client-assembly.ts +++ b/packages/client-generator/src/generators/typescript/client-assembly.ts @@ -10,16 +10,16 @@ // a sibling `.schemas.ts` the entry re-exports (`emitClientSplit`). // Text templates throughout — no `typescript` at generate time. -import type { EmitOptions } from '../generators/types.js'; +import { codeString } from '../../emitters/identifier.js'; +import { assembleInlineRuntime } from '../../emitters/inline-runtime.js'; import { allOperations, type ApiModel, type OperationModel, -} from '../intermediate-representation/model.js'; +} from '../../intermediate-representation/model.js'; +import type { EmitOptions } from '../types.js'; +import { banner, HEADER, renderTitleComment } from './banner.js'; import { packageIdents, renderDescriptors } from './descriptor.js'; -import { banner, HEADER, renderTitleComment } from './emit-options.js'; -import { codeString } from './identifier.js'; -import { assembleInlineRuntime } from './inline-runtime.js'; import { isTypedMultipart } from './operation-types.js'; import { collectEntrySchemaRefs, diff --git a/packages/client-generator/src/emitters/descriptor.ts b/packages/client-generator/src/generators/typescript/descriptor.ts similarity index 91% rename from packages/client-generator/src/emitters/descriptor.ts rename to packages/client-generator/src/generators/typescript/descriptor.ts index 7aab99b374..e103cf3737 100644 --- a/packages/client-generator/src/emitters/descriptor.ts +++ b/packages/client-generator/src/generators/typescript/descriptor.ts @@ -3,23 +3,23 @@ // descriptor map (`satisfies Record` — the semver skew // guard against the runtime contract in src/runtime/types.ts). Text templates. -import { securityRequirements } from '../authoring/operation.js'; -import type { DateType } from '../authoring/options.js'; -import type { ArgsStyle } from '../generators/types.js'; +import { securityRequirements } from '../../authoring/operation.js'; +import type { DateType } from '../../authoring/options.js'; +import { uniqueIdent } from '../../emitters/identifier.js'; +import type { ModelPagination } from '../../emitters/pagination.js'; +import { WIRING_NAMES } from '../../emitters/reserved-names.js'; +import { codeLiteral } from '../../emitters/ts-literal.js'; import { allOperations, type ApiModel, type NamedSchemaModel, type OperationModel, type SecuritySchemeModel, -} from '../intermediate-representation/model.js'; -import { uniqueIdent } from './identifier.js'; +} from '../../intermediate-representation/model.js'; +import type { ArgsStyle } from '../types.js'; import { isTypedMultipart } from './operation-types.js'; -import type { ModelPagination } from './pagination.js'; import { flatInputShape, responseText } from './render-client.js'; -import { WIRING_NAMES } from './reserved-names.js'; import { responseHeaderSpecs } from './response-headers.js'; -import { codeLiteral } from './ts-literal.js'; import { tsJsdoc } from './ts-type.js'; /** diff --git a/packages/client-generator/src/generators/typescript/index.ts b/packages/client-generator/src/generators/typescript/index.ts index e8deb8fdfd..1706b3ea66 100644 --- a/packages/client-generator/src/generators/typescript/index.ts +++ b/packages/client-generator/src/generators/typescript/index.ts @@ -1,10 +1,10 @@ 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 type { CodeSample, Generator, SampleContext } from '../types.js'; +import { emitClientSingleFile, emitClientSplit } from './client-assembly.js'; +import { packageIdents } from './descriptor.js'; /** * The default generator: the full typed client (model types + runtime + endpoints). diff --git a/packages/client-generator/src/emitters/operation-signature.ts b/packages/client-generator/src/generators/typescript/operation-signature.ts similarity index 93% rename from packages/client-generator/src/emitters/operation-signature.ts rename to packages/client-generator/src/generators/typescript/operation-signature.ts index fe9224a95e..093da9c385 100644 --- a/packages/client-generator/src/emitters/operation-signature.ts +++ b/packages/client-generator/src/generators/typescript/operation-signature.ts @@ -2,8 +2,8 @@ // operation's input type) and the wrapper generators (which forward it) read slot presence // and `Variables` naming from this one source, so a call and its type cannot drift. -import type { OperationModel, ParamModel } from '../intermediate-representation/model.js'; -import { pascalCase } from './support.js'; +import { pascalCase } from '../../emitters/support.js'; +import type { OperationModel, ParamModel } from '../../intermediate-representation/model.js'; export type OperationSignature = { /** Slot presence — which input layers the operation has. */ diff --git a/packages/client-generator/src/emitters/operation-types.ts b/packages/client-generator/src/generators/typescript/operation-types.ts similarity index 87% rename from packages/client-generator/src/emitters/operation-types.ts rename to packages/client-generator/src/generators/typescript/operation-types.ts index 9c670d038f..c61a015f77 100644 --- a/packages/client-generator/src/emitters/operation-types.ts +++ b/packages/client-generator/src/generators/typescript/operation-types.ts @@ -1,6 +1,6 @@ // Shared operation-shape predicates. -import type { RequestBodyModel } from '../intermediate-representation/model.js'; +import type { RequestBodyModel } from '../../intermediate-representation/model.js'; /** * A multipart body whose schema is a concrete object — the case worth typing. Such a body diff --git a/packages/client-generator/src/emitters/render-client.ts b/packages/client-generator/src/generators/typescript/render-client.ts similarity index 98% rename from packages/client-generator/src/emitters/render-client.ts rename to packages/client-generator/src/generators/typescript/render-client.ts index a1b985f075..ea2751e8e0 100644 --- a/packages/client-generator/src/emitters/render-client.ts +++ b/packages/client-generator/src/generators/typescript/render-client.ts @@ -1,5 +1,7 @@ -import type { DateType } from '../authoring/options.js'; -import type { ArgsStyle, ErrorMode } from '../generators/types.js'; +import type { DateType } from '../../authoring/options.js'; +import { safeIdent } from '../../emitters/identifier.js'; +import type { ModelPagination } from '../../emitters/pagination.js'; +import { pascalCase } from '../../emitters/support.js'; // 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`. @@ -12,13 +14,11 @@ import { type RequestBodyModel, type ResponseBodyModel, type SchemaModel, -} from '../intermediate-representation/model.js'; -import { safeIdent } from './identifier.js'; +} from '../../intermediate-representation/model.js'; +import type { ArgsStyle, ErrorMode } from '../types.js'; import { operationSignature, templatePathParams } from './operation-signature.js'; import { isTypedMultipart } from './operation-types.js'; -import type { ModelPagination } from './pagination.js'; import { responseHeadersTypeText } from './response-headers.js'; -import { pascalCase } from './support.js'; import { tsJsdoc, tsType } from './ts-type.js'; /** diff --git a/packages/client-generator/src/emitters/response-headers.ts b/packages/client-generator/src/generators/typescript/response-headers.ts similarity index 87% rename from packages/client-generator/src/emitters/response-headers.ts rename to packages/client-generator/src/generators/typescript/response-headers.ts index c6d7e52618..96f9427a8b 100644 --- a/packages/client-generator/src/emitters/response-headers.ts +++ b/packages/client-generator/src/generators/typescript/response-headers.ts @@ -1,15 +1,15 @@ // Success-response header helpers: descriptor parse hints + Ops / alias type text // for throw-mode `{ envelope: true }`. -import { headerCoerceType } from '../authoring/index.js'; +import { headerCoerceType } from '../../authoring/index.js'; +import { uniqueIdent } from '../../emitters/identifier.js'; +import { headerPropertyKey } from '../../emitters/support.js'; import type { NamedSchemaModel, ResponseHeaderModel, SchemaModel, -} from '../intermediate-representation/model.js'; -import type { ResponseHeaderSpec } from '../runtime/types.js'; -import { uniqueIdent } from './identifier.js'; -import { headerPropertyKey } from './support.js'; +} from '../../intermediate-representation/model.js'; +import type { ResponseHeaderSpec } from '../../runtime/types.js'; const INDENT = ' '; diff --git a/packages/client-generator/src/emitters/ts-type.ts b/packages/client-generator/src/generators/typescript/ts-type.ts similarity index 95% rename from packages/client-generator/src/emitters/ts-type.ts rename to packages/client-generator/src/generators/typescript/ts-type.ts index bce1cf24da..04340bf9cf 100644 --- a/packages/client-generator/src/emitters/ts-type.ts +++ b/packages/client-generator/src/generators/typescript/ts-type.ts @@ -2,16 +2,16 @@ // `typescript` import. Formatting contract: 4-space indent, double-quoted // literals, compound members parenthesized inside unions/intersections/arrays. -import type { DateType } from '../authoring/options.js'; +import type { DateType } from '../../authoring/options.js'; +import { isIdentifier, safeIdent } from '../../emitters/identifier.js'; +import { escapeJsDoc, jsdocText } from '../../emitters/jsdoc.js'; import type { NamedSchemaModel, PropertyModel, ScalarKind, SchemaMetadata, SchemaModel, -} from '../intermediate-representation/model.js'; -import { isIdentifier, safeIdent } from './identifier.js'; -import { escapeJsDoc, jsdocText } from './jsdoc.js'; +} from '../../intermediate-representation/model.js'; const INDENT = ' '; diff --git a/packages/client-generator/src/emitters/type-guards.ts b/packages/client-generator/src/generators/typescript/type-guards.ts similarity index 99% rename from packages/client-generator/src/emitters/type-guards.ts rename to packages/client-generator/src/generators/typescript/type-guards.ts index 747323dad3..7dbd72d6e5 100644 --- a/packages/client-generator/src/emitters/type-guards.ts +++ b/packages/client-generator/src/generators/typescript/type-guards.ts @@ -2,7 +2,7 @@ import type { DiscriminatorModel, NamedSchemaModel, SchemaModel, -} from '../intermediate-representation/model.js'; +} from '../../intermediate-representation/model.js'; /** * A discriminated union we can emit guards for, found while walking the schema From 5cd42d231f3507c286aca2d1e9ee1f80b8994b18 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 22:00:58 +0300 Subject: [PATCH 21/35] refactor: fold the standalone TS text helpers (identifier, ts-literal, jsdoc, support) into the TypeScript printer module --- .../src/emitters/__tests__/identifier.test.ts | 65 ------ .../src/emitters/__tests__/ts-literal.test.ts | 61 ----- .../src/emitters/identifier.ts | 79 ------- .../client-generator/src/emitters/jsdoc.ts | 76 ------ .../client-generator/src/emitters/support.ts | 40 ---- .../src/emitters/ts-literal.ts | 23 -- packages/client-generator/src/generate.ts | 6 +- .../src/generators/mock/faker.ts | 2 +- .../src/generators/mock/render.ts | 4 +- .../src/generators/mock/values.ts | 3 +- .../src/generators/swr/render.ts | 2 +- .../src/generators/tanstack-query/render.ts | 2 +- .../src/generators/transformers/render.ts | 3 +- .../src/generators/typescript/banner.ts | 3 +- .../generators/typescript/client-assembly.ts | 2 +- .../src/generators/typescript/descriptor.ts | 3 +- .../typescript/operation-signature.ts | 2 +- .../generators/typescript/render-client.ts | 3 +- .../generators/typescript/response-headers.ts | 3 +- .../src/generators/typescript/ts-type.ts | 3 +- .../src/generators/zod/schemas.ts | 4 +- .../sanitize-identifiers.ts | 3 +- .../__snapshots__/typescript.test.ts.snap} | 0 .../src/printers/__tests__/typescript.test.ts | 131 +++++++++++ .../src/printers/typescript.ts | 221 +++++++++++++++++- 25 files changed, 367 insertions(+), 377 deletions(-) delete mode 100644 packages/client-generator/src/emitters/__tests__/identifier.test.ts delete mode 100644 packages/client-generator/src/emitters/__tests__/ts-literal.test.ts delete mode 100644 packages/client-generator/src/emitters/identifier.ts delete mode 100644 packages/client-generator/src/emitters/jsdoc.ts delete mode 100644 packages/client-generator/src/emitters/support.ts delete mode 100644 packages/client-generator/src/emitters/ts-literal.ts rename packages/client-generator/src/{emitters/__tests__/__snapshots__/ts-literal.test.ts.snap => printers/__tests__/__snapshots__/typescript.test.ts.snap} (100%) create mode 100644 packages/client-generator/src/printers/__tests__/typescript.test.ts diff --git a/packages/client-generator/src/emitters/__tests__/identifier.test.ts b/packages/client-generator/src/emitters/__tests__/identifier.test.ts deleted file mode 100644 index fa5bc15b80..0000000000 --- a/packages/client-generator/src/emitters/__tests__/identifier.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { isIdentifier, safeIdent, uniqueIdent } from '../identifier.js'; - -describe('isIdentifier', () => { - it('accepts valid identifiers (letters, _, $, digits after the first char)', () => { - expect(isIdentifier('foo')).toBe(true); - expect(isIdentifier('_foo')).toBe(true); - expect(isIdentifier('$foo')).toBe(true); - expect(isIdentifier('foo123')).toBe(true); - }); - - it('rejects names that are not valid identifiers', () => { - expect(isIdentifier('foo-bar')).toBe(false); - expect(isIdentifier('2fa')).toBe(false); - expect(isIdentifier('has space')).toBe(false); - expect(isIdentifier('')).toBe(false); - }); -}); - -describe('safeIdent', () => { - it('returns a valid, non-reserved name bare', () => { - expect(safeIdent('limit')).toBe('limit'); - }); - - it('quotes a reserved word (a bare reserved word would not be a usable key)', () => { - expect(safeIdent('default')).toBe('"default"'); - }); - - it('quotes a name that is not a valid identifier', () => { - expect(safeIdent('X-Request-Id')).toBe('"X-Request-Id"'); - }); -}); - -describe('uniqueIdent', () => { - it('keeps a clean identifier unchanged and records it', () => { - const used = new Set(); - expect(uniqueIdent('orderId', used)).toBe('orderId'); - expect(used.has('orderId')).toBe(true); - }); - - it('replaces non-identifier characters with underscores', () => { - expect(uniqueIdent('pet-id', new Set())).toBe('pet_id'); - }); - - it('prefixes a leading digit with an underscore', () => { - expect(uniqueIdent('2fa', new Set())).toBe('_2fa'); - }); - - it('prefixes a reserved word with an underscore', () => { - expect(uniqueIdent('new', new Set())).toBe('_new'); - }); - - it('treats strict-mode reserved words as reserved (modules are always strict)', () => { - // GitHub's real description has a schema named `package`; `type X = package[]` is TS1214. - expect(uniqueIdent('package', new Set())).toBe('_package'); - expect(uniqueIdent('let', new Set())).toBe('_let'); - expect(uniqueIdent('await', new Set())).toBe('_await'); - }); - - it('suffixes collisions with an incrementing counter', () => { - const used = new Set(); - expect(uniqueIdent('a.b', used)).toBe('a_b'); - expect(uniqueIdent('a-b', used)).toBe('a_b_2'); - expect(uniqueIdent('a b', used)).toBe('a_b_3'); - }); -}); diff --git a/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts b/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts deleted file mode 100644 index 540ff8e705..0000000000 --- a/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { codeLiteral, sanitizeCodeString } from '../ts-literal.js'; - -// Literal expectations for the data-literal renderer (single-line, printer-style). -const CASES: Array<[string, unknown]> = [ - ['string', 'plain'], - ['string with quotes and backslashes', 'say "hi" \\ done'], - ['string with newline', 'a\nb'], - ['number', 42], - ['negative number', -3.5], - ['booleans', true], - ['null', null], - ['empty array', []], - ['array', ['a', 1, false]], - ['empty object', {}], - ['flat object', { id: 'getPet', method: 'GET', count: 2 }], - ['reserved-word key stays bare', { in: 'query', name: 'limit' }], - ['non-identifier key is quoted', { 'X-Request-Id': 'header', 'a-b': 1 }], - [ - 'nested descriptor-like shape', - { - id: 'listOrders', - path: '/orders/{id}', - params: [ - { name: 'id', in: 'path' }, - { name: 'page-size', in: 'query', explode: false }, - ], - security: [[{ scheme: 'Bearer', kind: 'bearer' }]], - pagination: { style: 'cursor', cursorParam: 'after', items: '/items' }, - }, - ], -]; - -describe('codeLiteral', () => { - it.each(CASES)('%s', (_label, value) => { - expect(codeLiteral(value)).toMatchSnapshot(); - }); -}); - -describe('sanitizeCodeString', () => { - // The literal must survive being read back: a sanitizer that escapes what - // `JSON.stringify` already escaped doubles the backslashes and, for a quote, ends the - // string early — emitting TypeScript that does not parse. - it.each([ - ['a newline', 'a\nb'], - ['a quote', 'quote " here'], - ['a backslash', 'C:\\path'], - ['a tab', 'tab\there'], - ['a line separator', 'a\u2028b'], - ['everything at once', 'a\n"b"\\c\u2029'], - ])('round-trips %s', (_label, value) => { - expect(JSON.parse(sanitizeCodeString(value))).toBe(value); - expect(JSON.parse(codeLiteral(value) as string)).toBe(value); - }); - - it('escapes the characters that break out of a code context', () => { - // `` must not survive intact into an inline script. - expect(sanitizeCodeString('')).not.toContain(''); - expect(sanitizeCodeString('')).toContain('\\u003C'); - expect(sanitizeCodeString('a\u2028b')).toContain('\\u2028'); - }); -}); diff --git a/packages/client-generator/src/emitters/identifier.ts b/packages/client-generator/src/emitters/identifier.ts deleted file mode 100644 index cdb8f0634f..0000000000 --- a/packages/client-generator/src/emitters/identifier.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { RESERVED_WORDS } from '../authoring/naming.js'; -// Identifier sanitization — mapping OpenAPI names (which may contain `-`, `.`, -// spaces, or be reserved words) onto valid TypeScript identifiers. Pure string -// logic with no dependency on the IR or other emitters. - -/** Matches a string that is already a valid JS identifier (ignoring reserved words). */ -const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/; - -// One list for the package: `identifierFor` (suffix convention) reads the same set. -const TS_RESERVED = RESERVED_WORDS.typescript; - -/** True when `name` matches the JS identifier grammar (reserved words still pass). */ -export function isIdentifier(name: string): boolean { - return IDENT_RE.test(name); -} - -/** True when `name` is a valid JS identifier AND not a reserved word — safe as a binding name. */ -export function isSafeIdentifier(name: string): boolean { - return IDENT_RE.test(name) && !TS_RESERVED.has(name); -} - -/** - * Coerce an arbitrary spec-supplied name into a valid, non-reserved JS identifier - * (no uniqueness guarantee — see `uniqueIdent`). Non-identifier characters become - * `_`; an empty result, a leading digit, or a reserved word is prefixed with `_`. - * This is the security boundary for any name that lands in a declaration slot — - * `ts.factory.createIdentifier` prints its text verbatim, so an unsanitized name - * like `foo(){};evil()` would emit as executable code. - */ -export function sanitizeIdentifier(name: string): string { - let base = name.replace(/[^A-Za-z0-9_$]/g, '_'); - if (base === '' || /^[0-9]/.test(base) || TS_RESERVED.has(base)) base = `_${base}`; - return base; -} - -/** - * A double-quoted TS string literal for generated code. One policy for the whole - * package — the stricter of the two that used to exist: U+2028/U+2029 (line terminators - * in JS source) AND `<`/`>` (a `` breakout when output lands in an inline - * script). Which protection applied used to depend on which escaper the caller imported. - */ -const CODE_UNSAFE: Record = { - '<': '\\u003C', - '>': '\\u003E', - '\u2028': '\\u2028', - '\u2029': '\\u2029', -}; - -export function codeString(value: string): string { - return JSON.stringify(value).replace(/[<>\u2028\u2029]/g, (char) => CODE_UNSAFE[char]); -} - -/** - * Render `name` as an object key or property name: bare when it is a valid, - * non-reserved identifier, quoted otherwise. Safe only where quoting is legal - * (object keys, property signatures) — not for binding names; use `uniqueIdent` - * there. - */ -export function safeIdent(name: string): string { - if (IDENT_RE.test(name) && !TS_RESERVED.has(name)) { - return name; - } - return codeString(name); -} - -/** - * `sanitizeIdentifier(name)` made unique within `used` (which it mutates): - * collisions get a `_2`, `_3`, … suffix. Used wherever a name lands in a binding - * slot that — unlike an object key — cannot be quoted (function/type/parameter - * names), so `safeIdent`'s quote-on-failure fallback would not compile. - */ -export function uniqueIdent(name: string, used: Set): string { - const base = sanitizeIdentifier(name); - let ident = base; - let n = 2; - while (used.has(ident)) ident = `${base}_${n++}`; - used.add(ident); - return ident; -} diff --git a/packages/client-generator/src/emitters/jsdoc.ts b/packages/client-generator/src/emitters/jsdoc.ts deleted file mode 100644 index c9a7f304b8..0000000000 --- a/packages/client-generator/src/emitters/jsdoc.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { SchemaMetadata } from '../intermediate-representation/model.js'; -import { splitLines } from './support.js'; - -/** Backslash-escape any comment-closing star-slash so it cannot terminate a block comment. */ -export function escapeJsDoc(text: string): string { - return text.replace(/\*\//g, '*\\/'); -} - -/** - * The JSDoc body for a description + metadata as a single `\n`-joined string, - * or `undefined` when there's nothing to document. The AST emitters feed this - * to `ts.ts`'s `jsdoc` helper (which owns the `*`-prefixing and indentation), - * so this returns only the raw body — no comment delimiters, no padding. - */ -export function jsdocText(text: string | undefined, metadata?: SchemaMetadata): string | undefined { - const lines = jsdocLines(text, metadata); - return lines.length === 0 ? undefined : lines.join('\n'); -} - -/** - * Build the body of a JSDoc block from a description and an optional metadata - * bag. Description lines come first (trimmed of leading/trailing blanks); then - * the metadata tag lines in a stable, source-driven order. - * - * Returns `[]` when there's nothing to render — callers use the empty result - * to skip emitting any JSDoc at all. - */ -function jsdocLines(text: string | undefined, metadata: SchemaMetadata | undefined): string[] { - const lines: string[] = []; - if (text && text.trim()) { - lines.push(...trimLines(splitLines(text))); - } - if (metadata) { - lines.push(...formatMetadata(metadata)); - } - return lines; -} - -/** - * Project a SchemaMetadata bag into JSDoc tag lines. - * - * Order matches the (near-)spec order so generated output is deterministic and - * diff-stable. `pattern` is escaped so an embedded `*​/` cannot terminate the - * surrounding JSDoc block. - */ -function formatMetadata(metadata: SchemaMetadata): string[] { - const lines: string[] = []; - const push = (tag: string, value?: number | string | boolean): void => { - if (value === undefined) { - lines.push(`@${tag}`); - } else { - lines.push(`@${tag} ${value}`); - } - }; - if (metadata.minimum !== undefined) push('minimum', metadata.minimum); - if (metadata.maximum !== undefined) push('maximum', metadata.maximum); - if (metadata.exclusiveMinimum !== undefined) push('exclusiveMinimum', metadata.exclusiveMinimum); - if (metadata.exclusiveMaximum !== undefined) push('exclusiveMaximum', metadata.exclusiveMaximum); - if (metadata.minLength !== undefined) push('minLength', metadata.minLength); - if (metadata.maxLength !== undefined) push('maxLength', metadata.maxLength); - if (metadata.pattern !== undefined) push('pattern', escapeJsDoc(metadata.pattern)); - if (metadata.minItems !== undefined) push('minItems', metadata.minItems); - if (metadata.maxItems !== undefined) push('maxItems', metadata.maxItems); - if (metadata.uniqueItems === true) push('uniqueItems'); - if (metadata.format !== undefined) push('format', metadata.format); - if (metadata.deprecated === true) push('deprecated'); - return lines; -} - -function trimLines(lines: string[]): string[] { - let start = 0; - let end = lines.length; - while (start < end && lines[start] === '') start++; - while (end > start && lines[end - 1] === '') end--; - return lines.slice(start, end); -} diff --git a/packages/client-generator/src/emitters/support.ts b/packages/client-generator/src/emitters/support.ts deleted file mode 100644 index 8844646229..0000000000 --- a/packages/client-generator/src/emitters/support.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Low-level text helpers shared across the emitters. Private to `emitters/`. - -import { sanitizeIdentifier } from './identifier.js'; - -/** - * Upper-case the first character of an operation name. We don't normalize the - * rest because almost every spec uses camelCase or PascalCase, and names that - * contain digits or `_` are passed through unchanged — the user named them that - * way for a reason. - * - * `op.name` reaches here already sanitized into a non-empty, valid TS identifier - * by the IR builder (see `intermediate-representation/sanitize-identifiers.ts`), so no empty-string or - * unsafe-character guard is needed. - */ -export function pascalCase(name: string): string { - return name[0].toUpperCase() + name.slice(1); -} - -/** - * CamelCase property key for a response-header wire name (`Pagination-Total` → - * `paginationTotal`). - */ -export function headerPropertyKey(wireName: string): string { - const camelCase = wireName - .split(/[-_]/) - .filter((part) => part.length > 0) - .map((part, index) => { - const lower = part.toLowerCase(); - return index === 0 ? lower : lower.charAt(0).toUpperCase() + lower.slice(1); - }) - .join(''); - return sanitizeIdentifier(camelCase); -} - -export function splitLines(text: string): string[] { - return text - .replace(/\r\n/g, '\n') - .split('\n') - .map((line) => line.trimEnd()); -} diff --git a/packages/client-generator/src/emitters/ts-literal.ts b/packages/client-generator/src/emitters/ts-literal.ts deleted file mode 100644 index 9a2edea6a6..0000000000 --- a/packages/client-generator/src/emitters/ts-literal.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Plain data → TypeScript expression text. Single-line (`{ a: 1, b: [2, 3] }`); -// keys stay bare when they pass the identifier GRAMMAR (reserved words are legal -// object-literal keys), quoted otherwise. - -import { codeString, isIdentifier } from './identifier.js'; - -/** The one string-literal policy, under this module's historical name. */ -export const sanitizeCodeString = codeString; - -/** A JSON-ish value as TypeScript source text. */ -export function codeLiteral(value: unknown): string { - if (typeof value === 'string') return codeString(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 : codeString(key)}: ${codeLiteral(entryValue)}` - ); - return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`; -} diff --git a/packages/client-generator/src/generate.ts b/packages/client-generator/src/generate.ts index 6b035aa7e9..720f61166f 100644 --- a/packages/client-generator/src/generate.ts +++ b/packages/client-generator/src/generate.ts @@ -21,14 +21,14 @@ import { runGenerators } from './pipeline.js'; // output language). `tsType`/`tsJsdoc`/`codeLiteral` are the TypeScript-specific // text renderers the sdk itself uses. export { tsJsdoc, tsType } from './generators/typescript/ts-type.js'; -export { codeLiteral } from './emitters/ts-literal.js'; +export { codeLiteral } from './printers/typescript.js'; // The language-neutral authoring helpers, re-exported here so both toolkit // entries offer the full authoring surface (the root offers them TS-free). export * from './authoring/index.js'; export { operationSignature } from './contracts/typescript.js'; export type { OperationSignature } from './contracts/typescript.js'; -export { pascalCase } from './emitters/support.js'; -export { safeIdent } from './emitters/identifier.js'; +export { pascalCase } from './printers/typescript.js'; +export { safeIdent } from './printers/typescript.js'; /** * Validate the generator selection (see `validateGenerators`), then run each diff --git a/packages/client-generator/src/generators/mock/faker.ts b/packages/client-generator/src/generators/mock/faker.ts index 5ab07594bb..5b2e57e40e 100644 --- a/packages/client-generator/src/generators/mock/faker.ts +++ b/packages/client-generator/src/generators/mock/faker.ts @@ -10,13 +10,13 @@ // dev-dep while the real client stays dependency-free. import type { DateType } from '../../authoring/options.js'; -import { codeLiteral } from '../../emitters/ts-literal.js'; import type { NamedSchemaModel, ScalarKind, SchemaMetadata, SchemaModel, } from '../../intermediate-representation/model.js'; +import { codeLiteral } from '../../printers/typescript.js'; import { splitIntersection } from './sample.js'; import { expr, isObjectValue, type MockEntry, type MockValue, objectValue } from './values.js'; diff --git a/packages/client-generator/src/generators/mock/render.ts b/packages/client-generator/src/generators/mock/render.ts index 988d6afb07..0e0992c276 100644 --- a/packages/client-generator/src/generators/mock/render.ts +++ b/packages/client-generator/src/generators/mock/render.ts @@ -8,9 +8,6 @@ import { isPlainObject } from '@redocly/openapi-core'; import type { DateType } from '../../authoring/options.js'; -import { isIdentifier } from '../../emitters/identifier.js'; -import { pascalCase } from '../../emitters/support.js'; -import { codeLiteral } from '../../emitters/ts-literal.js'; import { allOperations, type ApiModel, @@ -19,6 +16,7 @@ import { type ResponseBodyModel, type SchemaModel, } from '../../intermediate-representation/model.js'; +import { codeLiteral, isIdentifier, pascalCase } from '../../printers/typescript.js'; import { fakerExpression } from './faker.js'; import { sampleValue, SampleExpression } from './sample.js'; import { diff --git a/packages/client-generator/src/generators/mock/values.ts b/packages/client-generator/src/generators/mock/values.ts index bd557fc616..047621ac4c 100644 --- a/packages/client-generator/src/generators/mock/values.ts +++ b/packages/client-generator/src/generators/mock/values.ts @@ -2,8 +2,7 @@ // (for intersection merging and `...overrides` spreading) until the final render, // where indentation is threaded. Deliberately tiny. -import { safeIdent } from '../../emitters/identifier.js'; -import { sanitizeCodeString } from '../../emitters/ts-literal.js'; +import { safeIdent, sanitizeCodeString } from '../../printers/typescript.js'; export type MockEntry = { key: string; value: MockValue } | { spread: string }; diff --git a/packages/client-generator/src/generators/swr/render.ts b/packages/client-generator/src/generators/swr/render.ts index 148e99a018..5d0b85e5ee 100644 --- a/packages/client-generator/src/generators/swr/render.ts +++ b/packages/client-generator/src/generators/swr/render.ts @@ -16,8 +16,8 @@ import { variablesName, wrappableOperations, } from '../../contracts/typescript.js'; -import { pascalCase } from '../../emitters/support.js'; import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; +import { pascalCase } from '../../printers/typescript.js'; export type SwrOptions = { /** Import specifier for the sdk entry the operation functions/types live in. */ diff --git a/packages/client-generator/src/generators/tanstack-query/render.ts b/packages/client-generator/src/generators/tanstack-query/render.ts index 98762ce753..57473b120a 100644 --- a/packages/client-generator/src/generators/tanstack-query/render.ts +++ b/packages/client-generator/src/generators/tanstack-query/render.ts @@ -20,9 +20,9 @@ import { variablesName, wrappableOperations, } from '../../contracts/typescript.js'; -import { codeString, isSafeIdentifier, safeIdent } from '../../emitters/identifier.js'; import { type ModelPagination, resolveSchemaPointer } from '../../emitters/pagination.js'; import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; +import { codeString, isSafeIdentifier, safeIdent } from '../../printers/typescript.js'; import type { PaginationSpec } from '../../runtime/types.js'; export type TanstackOptions = { diff --git a/packages/client-generator/src/generators/transformers/render.ts b/packages/client-generator/src/generators/transformers/render.ts index 407fe7d950..2899dae092 100644 --- a/packages/client-generator/src/generators/transformers/render.ts +++ b/packages/client-generator/src/generators/transformers/render.ts @@ -9,13 +9,12 @@ // `transformPet` calls `transformOwner(data["owner"])` when `Pet.owner` is an // `Owner` that has dates. Source-text templates throughout. -import { safeIdent } from '../../emitters/identifier.js'; -import { pascalCase } from '../../emitters/support.js'; import type { ApiModel, NamedSchemaModel, SchemaModel, } from '../../intermediate-representation/model.js'; +import { pascalCase, safeIdent } from '../../printers/typescript.js'; const INDENT = ' '; diff --git a/packages/client-generator/src/generators/typescript/banner.ts b/packages/client-generator/src/generators/typescript/banner.ts index dd17f052a6..345eecd584 100644 --- a/packages/client-generator/src/generators/typescript/banner.ts +++ b/packages/client-generator/src/generators/typescript/banner.ts @@ -1,6 +1,5 @@ -import { escapeJsDoc } from '../../emitters/jsdoc.js'; -import { splitLines } from '../../emitters/support.js'; import type { ApiModel } from '../../intermediate-representation/model.js'; +import { escapeJsDoc, splitLines } from '../../printers/typescript.js'; /** The generated-by banner prepended to every emitted module. */ export const HEADER = `// Generated by @redocly/client-generator — do not edit by hand. diff --git a/packages/client-generator/src/generators/typescript/client-assembly.ts b/packages/client-generator/src/generators/typescript/client-assembly.ts index 980036eb27..3e9fd67c27 100644 --- a/packages/client-generator/src/generators/typescript/client-assembly.ts +++ b/packages/client-generator/src/generators/typescript/client-assembly.ts @@ -10,13 +10,13 @@ // a sibling `.schemas.ts` the entry re-exports (`emitClientSplit`). // Text templates throughout — no `typescript` at generate time. -import { codeString } from '../../emitters/identifier.js'; import { assembleInlineRuntime } from '../../emitters/inline-runtime.js'; import { allOperations, type ApiModel, type OperationModel, } from '../../intermediate-representation/model.js'; +import { codeString } from '../../printers/typescript.js'; import type { EmitOptions } from '../types.js'; import { banner, HEADER, renderTitleComment } from './banner.js'; import { packageIdents, renderDescriptors } from './descriptor.js'; diff --git a/packages/client-generator/src/generators/typescript/descriptor.ts b/packages/client-generator/src/generators/typescript/descriptor.ts index e103cf3737..814f7ed195 100644 --- a/packages/client-generator/src/generators/typescript/descriptor.ts +++ b/packages/client-generator/src/generators/typescript/descriptor.ts @@ -5,10 +5,8 @@ import { securityRequirements } from '../../authoring/operation.js'; import type { DateType } from '../../authoring/options.js'; -import { uniqueIdent } from '../../emitters/identifier.js'; import type { ModelPagination } from '../../emitters/pagination.js'; import { WIRING_NAMES } from '../../emitters/reserved-names.js'; -import { codeLiteral } from '../../emitters/ts-literal.js'; import { allOperations, type ApiModel, @@ -16,6 +14,7 @@ import { type OperationModel, type SecuritySchemeModel, } from '../../intermediate-representation/model.js'; +import { codeLiteral, uniqueIdent } from '../../printers/typescript.js'; import type { ArgsStyle } from '../types.js'; import { isTypedMultipart } from './operation-types.js'; import { flatInputShape, responseText } from './render-client.js'; diff --git a/packages/client-generator/src/generators/typescript/operation-signature.ts b/packages/client-generator/src/generators/typescript/operation-signature.ts index 093da9c385..92e609e067 100644 --- a/packages/client-generator/src/generators/typescript/operation-signature.ts +++ b/packages/client-generator/src/generators/typescript/operation-signature.ts @@ -2,8 +2,8 @@ // operation's input type) and the wrapper generators (which forward it) read slot presence // and `Variables` naming from this one source, so a call and its type cannot drift. -import { pascalCase } from '../../emitters/support.js'; import type { OperationModel, ParamModel } from '../../intermediate-representation/model.js'; +import { pascalCase } from '../../printers/typescript.js'; export type OperationSignature = { /** Slot presence — which input layers the operation has. */ diff --git a/packages/client-generator/src/generators/typescript/render-client.ts b/packages/client-generator/src/generators/typescript/render-client.ts index ea2751e8e0..2b2e8c8ecf 100644 --- a/packages/client-generator/src/generators/typescript/render-client.ts +++ b/packages/client-generator/src/generators/typescript/render-client.ts @@ -1,7 +1,5 @@ import type { DateType } from '../../authoring/options.js'; -import { safeIdent } from '../../emitters/identifier.js'; import type { ModelPagination } from '../../emitters/pagination.js'; -import { pascalCase } from '../../emitters/support.js'; // 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`. @@ -15,6 +13,7 @@ import { type ResponseBodyModel, type SchemaModel, } from '../../intermediate-representation/model.js'; +import { pascalCase, safeIdent } from '../../printers/typescript.js'; import type { ArgsStyle, ErrorMode } from '../types.js'; import { operationSignature, templatePathParams } from './operation-signature.js'; import { isTypedMultipart } from './operation-types.js'; diff --git a/packages/client-generator/src/generators/typescript/response-headers.ts b/packages/client-generator/src/generators/typescript/response-headers.ts index 96f9427a8b..6033c58713 100644 --- a/packages/client-generator/src/generators/typescript/response-headers.ts +++ b/packages/client-generator/src/generators/typescript/response-headers.ts @@ -2,13 +2,12 @@ // for throw-mode `{ envelope: true }`. import { headerCoerceType } from '../../authoring/index.js'; -import { uniqueIdent } from '../../emitters/identifier.js'; -import { headerPropertyKey } from '../../emitters/support.js'; import type { NamedSchemaModel, ResponseHeaderModel, SchemaModel, } from '../../intermediate-representation/model.js'; +import { headerPropertyKey, uniqueIdent } from '../../printers/typescript.js'; import type { ResponseHeaderSpec } from '../../runtime/types.js'; const INDENT = ' '; diff --git a/packages/client-generator/src/generators/typescript/ts-type.ts b/packages/client-generator/src/generators/typescript/ts-type.ts index 04340bf9cf..3512815ba3 100644 --- a/packages/client-generator/src/generators/typescript/ts-type.ts +++ b/packages/client-generator/src/generators/typescript/ts-type.ts @@ -3,8 +3,6 @@ // literals, compound members parenthesized inside unions/intersections/arrays. import type { DateType } from '../../authoring/options.js'; -import { isIdentifier, safeIdent } from '../../emitters/identifier.js'; -import { escapeJsDoc, jsdocText } from '../../emitters/jsdoc.js'; import type { NamedSchemaModel, PropertyModel, @@ -12,6 +10,7 @@ import type { SchemaMetadata, SchemaModel, } from '../../intermediate-representation/model.js'; +import { escapeJsDoc, isIdentifier, jsdocText, safeIdent } from '../../printers/typescript.js'; const INDENT = ' '; diff --git a/packages/client-generator/src/generators/zod/schemas.ts b/packages/client-generator/src/generators/zod/schemas.ts index 0a1c43c6df..2230e095b1 100644 --- a/packages/client-generator/src/generators/zod/schemas.ts +++ b/packages/client-generator/src/generators/zod/schemas.ts @@ -9,9 +9,6 @@ // between major versions and are deferred. Refs become `z.lazy(() => …Schema)`, // which sidesteps declaration ordering and recursion uniformly. -import { safeIdent } from '../../emitters/identifier.js'; -import { pascalCase } from '../../emitters/support.js'; -import { codeLiteral } from '../../emitters/ts-literal.js'; import { allOperations, type ApiModel, @@ -20,6 +17,7 @@ import { type SchemaMetadata, type SchemaModel, } from '../../intermediate-representation/model.js'; +import { codeLiteral, pascalCase, safeIdent } from '../../printers/typescript.js'; const INDENT = ' '; diff --git a/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts b/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts index d88e752d4b..33762e4c86 100644 --- a/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts +++ b/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts @@ -1,8 +1,7 @@ import { logger } from '@redocly/openapi-core'; -import { isSafeIdentifier, sanitizeIdentifier } from '../emitters/identifier.js'; import { reservedModuleNames } from '../emitters/reserved-names.js'; -import { pascalCase } from '../emitters/support.js'; +import { isSafeIdentifier, pascalCase, sanitizeIdentifier } from '../printers/typescript.js'; import type { ApiModel, OperationModel, SchemaModel } from './model.js'; /** diff --git a/packages/client-generator/src/emitters/__tests__/__snapshots__/ts-literal.test.ts.snap b/packages/client-generator/src/printers/__tests__/__snapshots__/typescript.test.ts.snap similarity index 100% rename from packages/client-generator/src/emitters/__tests__/__snapshots__/ts-literal.test.ts.snap rename to packages/client-generator/src/printers/__tests__/__snapshots__/typescript.test.ts.snap diff --git a/packages/client-generator/src/printers/__tests__/typescript.test.ts b/packages/client-generator/src/printers/__tests__/typescript.test.ts new file mode 100644 index 0000000000..c632b2b678 --- /dev/null +++ b/packages/client-generator/src/printers/__tests__/typescript.test.ts @@ -0,0 +1,131 @@ +import { + codeLiteral, + isIdentifier, + safeIdent, + sanitizeCodeString, + uniqueIdent, +} from '../typescript.js'; + +describe('isIdentifier', () => { + it('accepts valid identifiers (letters, _, $, digits after the first char)', () => { + expect(isIdentifier('foo')).toBe(true); + expect(isIdentifier('_foo')).toBe(true); + expect(isIdentifier('$foo')).toBe(true); + expect(isIdentifier('foo123')).toBe(true); + }); + + it('rejects names that are not valid identifiers', () => { + expect(isIdentifier('foo-bar')).toBe(false); + expect(isIdentifier('2fa')).toBe(false); + expect(isIdentifier('has space')).toBe(false); + expect(isIdentifier('')).toBe(false); + }); +}); + +describe('safeIdent', () => { + it('returns a valid, non-reserved name bare', () => { + expect(safeIdent('limit')).toBe('limit'); + }); + + it('quotes a reserved word (a bare reserved word would not be a usable key)', () => { + expect(safeIdent('default')).toBe('"default"'); + }); + + it('quotes a name that is not a valid identifier', () => { + expect(safeIdent('X-Request-Id')).toBe('"X-Request-Id"'); + }); +}); + +describe('uniqueIdent', () => { + it('keeps a clean identifier unchanged and records it', () => { + const used = new Set(); + expect(uniqueIdent('orderId', used)).toBe('orderId'); + expect(used.has('orderId')).toBe(true); + }); + + it('replaces non-identifier characters with underscores', () => { + expect(uniqueIdent('pet-id', new Set())).toBe('pet_id'); + }); + + it('prefixes a leading digit with an underscore', () => { + expect(uniqueIdent('2fa', new Set())).toBe('_2fa'); + }); + + it('prefixes a reserved word with an underscore', () => { + expect(uniqueIdent('new', new Set())).toBe('_new'); + }); + + it('treats strict-mode reserved words as reserved (modules are always strict)', () => { + // GitHub's real description has a schema named `package`; `type X = package[]` is TS1214. + expect(uniqueIdent('package', new Set())).toBe('_package'); + expect(uniqueIdent('let', new Set())).toBe('_let'); + expect(uniqueIdent('await', new Set())).toBe('_await'); + }); + + it('suffixes collisions with an incrementing counter', () => { + const used = new Set(); + expect(uniqueIdent('a.b', used)).toBe('a_b'); + expect(uniqueIdent('a-b', used)).toBe('a_b_2'); + expect(uniqueIdent('a b', used)).toBe('a_b_3'); + }); +}); + +// Literal expectations for the data-literal renderer (single-line, printer-style). +const CASES: Array<[string, unknown]> = [ + ['string', 'plain'], + ['string with quotes and backslashes', 'say "hi" \\ done'], + ['string with newline', 'a\nb'], + ['number', 42], + ['negative number', -3.5], + ['booleans', true], + ['null', null], + ['empty array', []], + ['array', ['a', 1, false]], + ['empty object', {}], + ['flat object', { id: 'getPet', method: 'GET', count: 2 }], + ['reserved-word key stays bare', { in: 'query', name: 'limit' }], + ['non-identifier key is quoted', { 'X-Request-Id': 'header', 'a-b': 1 }], + [ + 'nested descriptor-like shape', + { + id: 'listOrders', + path: '/orders/{id}', + params: [ + { name: 'id', in: 'path' }, + { name: 'page-size', in: 'query', explode: false }, + ], + security: [[{ scheme: 'Bearer', kind: 'bearer' }]], + pagination: { style: 'cursor', cursorParam: 'after', items: '/items' }, + }, + ], +]; + +describe('codeLiteral', () => { + it.each(CASES)('%s', (_label, value) => { + expect(codeLiteral(value)).toMatchSnapshot(); + }); +}); + +describe('sanitizeCodeString', () => { + // The literal must survive being read back: a sanitizer that escapes what + // `JSON.stringify` already escaped doubles the backslashes and, for a quote, ends the + // string early — emitting TypeScript that does not parse. + it.each([ + ['a newline', 'a\nb'], + ['a quote', 'quote " here'], + ['a backslash', 'C:\\path'], + ['a tab', 'tab\there'], + ['a line separator', 'a\u2028b'], + ['everything at once', 'a\n"b"\\c\u2029'], + ])('round-trips %s', (_label, value) => { + expect(JSON.parse(sanitizeCodeString(value))).toBe(value); + expect(JSON.parse(codeLiteral(value) as string)).toBe(value); + }); + + it('escapes the characters that break out of a code context', () => { + // `` must not survive intact into an inline script. + expect(sanitizeCodeString('')).not.toContain(''); + expect(sanitizeCodeString('')).toContain('\\u003C'); + expect(sanitizeCodeString('a\u2028b')).toContain('\\u2028'); + }); +}); diff --git a/packages/client-generator/src/printers/typescript.ts b/packages/client-generator/src/printers/typescript.ts index 43190ca109..30de4de1b1 100644 --- a/packages/client-generator/src/printers/typescript.ts +++ b/packages/client-generator/src/printers/typescript.ts @@ -4,11 +4,10 @@ // in JS source) plus `<`/`>` (a `` breakout when output lands in an inline // script) — previously two escapers with different protections, split by import site. +import { RESERVED_WORDS } from '../authoring/naming.js'; import { Printer } from '../authoring/printer.js'; import { docText } from '../authoring/schema.js'; -import { isSafeIdentifier, sanitizeIdentifier, uniqueIdent } from '../emitters/identifier.js'; -import { pascalCase } from '../emitters/support.js'; -import { codeLiteral, sanitizeCodeString } from '../emitters/ts-literal.js'; +import type { SchemaMetadata } from '../intermediate-representation/model.js'; export class TypeScriptPrinter extends Printer { constructor() { @@ -66,3 +65,219 @@ export class TypeScriptPrinter extends Printer { return this.line(' */'); } } + +// ─── Identifier mechanics ─── + +// Identifier sanitization — mapping OpenAPI names (which may contain `-`, `.`, +// spaces, or be reserved words) onto valid TypeScript identifiers. Pure string +// logic with no dependency on the IR or other emitters. + +/** Matches a string that is already a valid JS identifier (ignoring reserved words). */ +const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +// One list for the package: `identifierFor` (suffix convention) reads the same set. +const TS_RESERVED = RESERVED_WORDS.typescript; + +/** True when `name` matches the JS identifier grammar (reserved words still pass). */ +export function isIdentifier(name: string): boolean { + return IDENT_RE.test(name); +} + +/** True when `name` is a valid JS identifier AND not a reserved word — safe as a binding name. */ +export function isSafeIdentifier(name: string): boolean { + return IDENT_RE.test(name) && !TS_RESERVED.has(name); +} + +/** + * Coerce an arbitrary spec-supplied name into a valid, non-reserved JS identifier + * (no uniqueness guarantee — see `uniqueIdent`). Non-identifier characters become + * `_`; an empty result, a leading digit, or a reserved word is prefixed with `_`. + * This is the security boundary for any name that lands in a declaration slot — + * `ts.factory.createIdentifier` prints its text verbatim, so an unsanitized name + * like `foo(){};evil()` would emit as executable code. + */ +export function sanitizeIdentifier(name: string): string { + let base = name.replace(/[^A-Za-z0-9_$]/g, '_'); + if (base === '' || /^[0-9]/.test(base) || TS_RESERVED.has(base)) base = `_${base}`; + return base; +} + +/** + * A double-quoted TS string literal for generated code. One policy for the whole + * package — the stricter of the two that used to exist: U+2028/U+2029 (line terminators + * in JS source) AND `<`/`>` (a `` breakout when output lands in an inline + * script). Which protection applied used to depend on which escaper the caller imported. + */ +const CODE_UNSAFE: Record = { + '<': '\\u003C', + '>': '\\u003E', + '\u2028': '\\u2028', + '\u2029': '\\u2029', +}; + +export function codeString(value: string): string { + return JSON.stringify(value).replace(/[<>\u2028\u2029]/g, (char) => CODE_UNSAFE[char]); +} + +/** + * Render `name` as an object key or property name: bare when it is a valid, + * non-reserved identifier, quoted otherwise. Safe only where quoting is legal + * (object keys, property signatures) — not for binding names; use `uniqueIdent` + * there. + */ +export function safeIdent(name: string): string { + if (IDENT_RE.test(name) && !TS_RESERVED.has(name)) { + return name; + } + return codeString(name); +} + +/** + * `sanitizeIdentifier(name)` made unique within `used` (which it mutates): + * collisions get a `_2`, `_3`, … suffix. Used wherever a name lands in a binding + * slot that — unlike an object key — cannot be quoted (function/type/parameter + * names), so `safeIdent`'s quote-on-failure fallback would not compile. + */ +export function uniqueIdent(name: string, used: Set): string { + const base = sanitizeIdentifier(name); + let ident = base; + let n = 2; + while (used.has(ident)) ident = `${base}_${n++}`; + used.add(ident); + return ident; +} + +// ─── Literals ─── + +/** The one string-literal policy, under this module's historical name. */ +export const sanitizeCodeString = codeString; + +/** A JSON-ish value as TypeScript source text. */ +export function codeLiteral(value: unknown): string { + if (typeof value === 'string') return codeString(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 : codeString(key)}: ${codeLiteral(entryValue)}` + ); + return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`; +} + +// ─── JSDoc ─── + +/** Backslash-escape any comment-closing star-slash so it cannot terminate a block comment. */ +export function escapeJsDoc(text: string): string { + return text.replace(/\*\//g, '*\\/'); +} + +/** + * The JSDoc body for a description + metadata as a single `\n`-joined string, + * or `undefined` when there's nothing to document. The AST emitters feed this + * to `ts.ts`'s `jsdoc` helper (which owns the `*`-prefixing and indentation), + * so this returns only the raw body — no comment delimiters, no padding. + */ +export function jsdocText(text: string | undefined, metadata?: SchemaMetadata): string | undefined { + const lines = jsdocLines(text, metadata); + return lines.length === 0 ? undefined : lines.join('\n'); +} + +/** + * Build the body of a JSDoc block from a description and an optional metadata + * bag. Description lines come first (trimmed of leading/trailing blanks); then + * the metadata tag lines in a stable, source-driven order. + * + * Returns `[]` when there's nothing to render — callers use the empty result + * to skip emitting any JSDoc at all. + */ +function jsdocLines(text: string | undefined, metadata: SchemaMetadata | undefined): string[] { + const lines: string[] = []; + if (text && text.trim()) { + lines.push(...trimLines(splitLines(text))); + } + if (metadata) { + lines.push(...formatMetadata(metadata)); + } + return lines; +} + +/** + * Project a SchemaMetadata bag into JSDoc tag lines. + * + * Order matches the (near-)spec order so generated output is deterministic and + * diff-stable. `pattern` is escaped so an embedded `*​/` cannot terminate the + * surrounding JSDoc block. + */ +function formatMetadata(metadata: SchemaMetadata): string[] { + const lines: string[] = []; + const push = (tag: string, value?: number | string | boolean): void => { + if (value === undefined) { + lines.push(`@${tag}`); + } else { + lines.push(`@${tag} ${value}`); + } + }; + if (metadata.minimum !== undefined) push('minimum', metadata.minimum); + if (metadata.maximum !== undefined) push('maximum', metadata.maximum); + if (metadata.exclusiveMinimum !== undefined) push('exclusiveMinimum', metadata.exclusiveMinimum); + if (metadata.exclusiveMaximum !== undefined) push('exclusiveMaximum', metadata.exclusiveMaximum); + if (metadata.minLength !== undefined) push('minLength', metadata.minLength); + if (metadata.maxLength !== undefined) push('maxLength', metadata.maxLength); + if (metadata.pattern !== undefined) push('pattern', escapeJsDoc(metadata.pattern)); + if (metadata.minItems !== undefined) push('minItems', metadata.minItems); + if (metadata.maxItems !== undefined) push('maxItems', metadata.maxItems); + if (metadata.uniqueItems === true) push('uniqueItems'); + if (metadata.format !== undefined) push('format', metadata.format); + if (metadata.deprecated === true) push('deprecated'); + return lines; +} + +function trimLines(lines: string[]): string[] { + let start = 0; + let end = lines.length; + while (start < end && lines[start] === '') start++; + while (end > start && lines[end - 1] === '') end--; + return lines.slice(start, end); +} + +// ─── Casing and text support ─── + +/** + * Upper-case the first character of an operation name. We don't normalize the + * rest because almost every spec uses camelCase or PascalCase, and names that + * contain digits or `_` are passed through unchanged — the user named them that + * way for a reason. + * + * `op.name` reaches here already sanitized into a non-empty, valid TS identifier + * by the IR builder (see `intermediate-representation/sanitize-identifiers.ts`), so no empty-string or + * unsafe-character guard is needed. + */ +export function pascalCase(name: string): string { + return name[0].toUpperCase() + name.slice(1); +} + +/** + * CamelCase property key for a response-header wire name (`Pagination-Total` → + * `paginationTotal`). + */ +export function headerPropertyKey(wireName: string): string { + const camelCase = wireName + .split(/[-_]/) + .filter((part) => part.length > 0) + .map((part, index) => { + const lower = part.toLowerCase(); + return index === 0 ? lower : lower.charAt(0).toUpperCase() + lower.slice(1); + }) + .join(''); + return sanitizeIdentifier(camelCase); +} + +export function splitLines(text: string): string[] { + return text + .replace(/\r\n/g, '\n') + .split('\n') + .map((line) => line.trimEnd()); +} From f07738e770cfc2b8ee47a340b1b93342e3289f23 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 22:02:36 +0300 Subject: [PATCH 22/35] refactor: move the pagination resolver to the pipeline layer (src/pagination.ts) --- .../src/{emitters => }/__tests__/pagination.test.ts | 11 +++++++++-- .../generators/__tests__/fixtures/generator-input.ts | 2 +- .../src/generators/cli/__tests__/render.test.ts | 2 +- .../client-generator/src/generators/cli/render.ts | 2 +- .../tanstack-query/__tests__/render.test.ts | 2 +- .../src/generators/tanstack-query/render.ts | 2 +- packages/client-generator/src/generators/types.ts | 2 +- .../typescript/__tests__/client-assembly.test.ts | 2 +- .../typescript/__tests__/descriptor.test.ts | 2 +- .../src/generators/typescript/descriptor.ts | 2 +- .../src/generators/typescript/render-client.ts | 2 +- packages/client-generator/src/index.ts | 2 +- .../client-generator/src/{emitters => }/pagination.ts | 8 ++++---- packages/client-generator/src/pipeline.ts | 2 +- packages/client-generator/src/types.ts | 2 +- 15 files changed, 26 insertions(+), 19 deletions(-) rename packages/client-generator/src/{emitters => }/__tests__/pagination.test.ts (99%) rename packages/client-generator/src/{emitters => }/pagination.ts (98%) diff --git a/packages/client-generator/src/emitters/__tests__/pagination.test.ts b/packages/client-generator/src/__tests__/pagination.test.ts similarity index 99% rename from packages/client-generator/src/emitters/__tests__/pagination.test.ts rename to packages/client-generator/src/__tests__/pagination.test.ts index 8475d40f89..1e3b805156 100644 --- a/packages/client-generator/src/emitters/__tests__/pagination.test.ts +++ b/packages/client-generator/src/__tests__/pagination.test.ts @@ -1,15 +1,22 @@ +import { + apiModel, + namedSchema, + operation, + param, + response, + SCALAR, +} from '../emitters/__tests__/fixtures.js'; import type { ApiModel, OperationModel, SchemaModel, -} from '../../intermediate-representation/model.js'; +} from '../intermediate-representation/model.js'; import { type PaginationRule, resolveModelPagination, resolveOperationPagination, resolveSchemaPointer, } from '../pagination.js'; -import { apiModel, namedSchema, operation, param, response, SCALAR } from './fixtures.js'; const ORDER: SchemaModel = { kind: 'object', diff --git a/packages/client-generator/src/generators/__tests__/fixtures/generator-input.ts b/packages/client-generator/src/generators/__tests__/fixtures/generator-input.ts index d444a47041..9564225bd9 100644 --- a/packages/client-generator/src/generators/__tests__/fixtures/generator-input.ts +++ b/packages/client-generator/src/generators/__tests__/fixtures/generator-input.ts @@ -1,6 +1,6 @@ import { parse } from 'node:path'; -import { resolveModelPagination } from '../../../emitters/pagination.js'; +import { resolveModelPagination } from '../../../pagination.js'; import type { GeneratorInput } from '../../types.js'; /** The banner lines the pipeline derives from `HEADER` for every run. */ diff --git a/packages/client-generator/src/generators/cli/__tests__/render.test.ts b/packages/client-generator/src/generators/cli/__tests__/render.test.ts index f9b611aa7c..7ebd4489b0 100644 --- a/packages/client-generator/src/generators/cli/__tests__/render.test.ts +++ b/packages/client-generator/src/generators/cli/__tests__/render.test.ts @@ -1,7 +1,7 @@ import { logger } from '@redocly/openapi-core'; -import { resolveModelPagination } from '../../../emitters/pagination.js'; import type { ApiModel, SchemaModel } from '../../../intermediate-representation/model.js'; +import { resolveModelPagination } from '../../../pagination.js'; import { commandData, renderCliModule, renderComposedCliEntry } from '../render.js'; const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' }; diff --git a/packages/client-generator/src/generators/cli/render.ts b/packages/client-generator/src/generators/cli/render.ts index 3ac3b514bf..ee4afcce37 100644 --- a/packages/client-generator/src/generators/cli/render.ts +++ b/packages/client-generator/src/generators/cli/render.ts @@ -7,13 +7,13 @@ import { logger } from '@redocly/openapi-core'; import { casing } from '../../authoring/naming.js'; import { flatInputShape } from '../../contracts/typescript.js'; import { embedCliRuntime } from '../../emitters/inline-runtime.js'; -import type { ModelPagination } from '../../emitters/pagination.js'; import type { ApiModel, OperationModel, ParamModel, SchemaModel, } from '../../intermediate-representation/model.js'; +import type { ModelPagination } from '../../pagination.js'; import { constantCase, groupSlug, diff --git a/packages/client-generator/src/generators/tanstack-query/__tests__/render.test.ts b/packages/client-generator/src/generators/tanstack-query/__tests__/render.test.ts index 814a7df176..c93a3c262b 100644 --- a/packages/client-generator/src/generators/tanstack-query/__tests__/render.test.ts +++ b/packages/client-generator/src/generators/tanstack-query/__tests__/render.test.ts @@ -5,7 +5,7 @@ import { param, SCALAR, } from '../../../emitters/__tests__/fixtures.js'; -import { resolveModelPagination, type PaginationConfig } from '../../../emitters/pagination.js'; +import { resolveModelPagination, type PaginationConfig } from '../../../pagination.js'; import { renderTanstackModule } from '../render.js'; const SDK = './client.js'; diff --git a/packages/client-generator/src/generators/tanstack-query/render.ts b/packages/client-generator/src/generators/tanstack-query/render.ts index 57473b120a..8b8edced90 100644 --- a/packages/client-generator/src/generators/tanstack-query/render.ts +++ b/packages/client-generator/src/generators/tanstack-query/render.ts @@ -20,8 +20,8 @@ import { variablesName, wrappableOperations, } from '../../contracts/typescript.js'; -import { type ModelPagination, resolveSchemaPointer } from '../../emitters/pagination.js'; import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; +import { type ModelPagination, resolveSchemaPointer } from '../../pagination.js'; import { codeString, isSafeIdentifier, safeIdent } from '../../printers/typescript.js'; import type { PaginationSpec } from '../../runtime/types.js'; diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index 5e9ec1f767..ea029e139d 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -1,6 +1,6 @@ import type { DateType } from '../authoring/options.js'; -import type { ModelPagination } from '../emitters/pagination.js'; import type { ApiModel, OperationModel } from '../intermediate-representation/model.js'; +import type { ModelPagination } from '../pagination.js'; export type { DateType } from '../authoring/options.js'; diff --git a/packages/client-generator/src/generators/typescript/__tests__/client-assembly.test.ts b/packages/client-generator/src/generators/typescript/__tests__/client-assembly.test.ts index b0c3a5abca..c3aef689d7 100644 --- a/packages/client-generator/src/generators/typescript/__tests__/client-assembly.test.ts +++ b/packages/client-generator/src/generators/typescript/__tests__/client-assembly.test.ts @@ -8,8 +8,8 @@ import { response, SCALAR, } from '../../../emitters/__tests__/fixtures.js'; -import { resolveModelPagination } from '../../../emitters/pagination.js'; import type { ApiModel } from '../../../intermediate-representation/model.js'; +import { resolveModelPagination } from '../../../pagination.js'; import type { EmitOptions } from '../../types.js'; import { emitClientSingleFile } from '../client-assembly.js'; diff --git a/packages/client-generator/src/generators/typescript/__tests__/descriptor.test.ts b/packages/client-generator/src/generators/typescript/__tests__/descriptor.test.ts index 4ec0610583..b641db8562 100644 --- a/packages/client-generator/src/generators/typescript/__tests__/descriptor.test.ts +++ b/packages/client-generator/src/generators/typescript/__tests__/descriptor.test.ts @@ -1,10 +1,10 @@ import { apiModel, modelWith, operation, param } from '../../../emitters/__tests__/fixtures.js'; -import type { ModelPagination } from '../../../emitters/pagination.js'; import type { ApiModel, OperationModel, ResponseBodyModel, } from '../../../intermediate-representation/model.js'; +import type { ModelPagination } from '../../../pagination.js'; import { packageIdents, renderDescriptors } from '../descriptor.js'; import { type EmitContext, renderOpsType } from '../render-client.js'; diff --git a/packages/client-generator/src/generators/typescript/descriptor.ts b/packages/client-generator/src/generators/typescript/descriptor.ts index 814f7ed195..2dcf13b084 100644 --- a/packages/client-generator/src/generators/typescript/descriptor.ts +++ b/packages/client-generator/src/generators/typescript/descriptor.ts @@ -5,7 +5,6 @@ import { securityRequirements } from '../../authoring/operation.js'; import type { DateType } from '../../authoring/options.js'; -import type { ModelPagination } from '../../emitters/pagination.js'; import { WIRING_NAMES } from '../../emitters/reserved-names.js'; import { allOperations, @@ -14,6 +13,7 @@ import { type OperationModel, type SecuritySchemeModel, } from '../../intermediate-representation/model.js'; +import type { ModelPagination } from '../../pagination.js'; import { codeLiteral, uniqueIdent } from '../../printers/typescript.js'; import type { ArgsStyle } from '../types.js'; import { isTypedMultipart } from './operation-types.js'; diff --git a/packages/client-generator/src/generators/typescript/render-client.ts b/packages/client-generator/src/generators/typescript/render-client.ts index 2b2e8c8ecf..0ccea3ebb6 100644 --- a/packages/client-generator/src/generators/typescript/render-client.ts +++ b/packages/client-generator/src/generators/typescript/render-client.ts @@ -1,5 +1,4 @@ import type { DateType } from '../../authoring/options.js'; -import type { ModelPagination } from '../../emitters/pagination.js'; // 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`. @@ -13,6 +12,7 @@ import { type ResponseBodyModel, type SchemaModel, } from '../../intermediate-representation/model.js'; +import type { ModelPagination } from '../../pagination.js'; import { pascalCase, safeIdent } from '../../printers/typescript.js'; import type { ArgsStyle, ErrorMode } from '../types.js'; import { operationSignature, templatePathParams } from './operation-signature.js'; diff --git a/packages/client-generator/src/index.ts b/packages/client-generator/src/index.ts index caf6f35d65..01b1161009 100644 --- a/packages/client-generator/src/index.ts +++ b/packages/client-generator/src/index.ts @@ -62,7 +62,7 @@ export type { 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 { PaginationConfig, PaginationRule, PaginationStyle } from './pagination.js'; export type { GenerateClientConfig, GenerateClientOptions, diff --git a/packages/client-generator/src/emitters/pagination.ts b/packages/client-generator/src/pagination.ts similarity index 98% rename from packages/client-generator/src/emitters/pagination.ts rename to packages/client-generator/src/pagination.ts index 655cc6ce62..2cbb46c59b 100644 --- a/packages/client-generator/src/emitters/pagination.ts +++ b/packages/client-generator/src/pagination.ts @@ -8,14 +8,14 @@ import { isPlainObject, logger } from '@redocly/openapi-core'; -import { schemaAtPointer as resolveSchemaPointer } from '../authoring/schema.js'; +import { schemaAtPointer as resolveSchemaPointer } from './authoring/schema.js'; import { allOperations, type ApiModel, type OperationModel, type SchemaModel, -} from '../intermediate-representation/model.js'; -import type { PaginationSpec } from '../runtime/types.js'; +} from './intermediate-representation/model.js'; +import type { PaginationSpec } from './runtime/types.js'; /** The pagination styles the generated runtime can drive. */ export type PaginationStyle = 'cursor' | 'offset' | 'page' | 'link'; @@ -278,7 +278,7 @@ function ruleShapeProblem(rule: unknown): string | undefined { } /** The neutral RFC 6901 schema walker, re-exported under its original name here. */ -export { schemaAtPointer as resolveSchemaPointer } from '../authoring/schema.js'; +export { schemaAtPointer as resolveSchemaPointer } from './authoring/schema.js'; /** A (dereferenced) schema named for a fit-error message; scalars/enums by their scalar. */ function describeSchema(schema: SchemaModel | undefined): string { diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index ba54d5e7d9..9b7b29438f 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -10,7 +10,6 @@ import { logger, stringifyYaml } from '@redocly/openapi-core'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, parse, resolve, sep } from 'node:path'; -import { resolveModelPagination, type ModelPagination } from './emitters/pagination.js'; import { NotSupportedError } from './errors.js'; import { validateSelection } from './generators/meta.js'; import { resolveGeneratorOptions } from './generators/options.js'; @@ -26,6 +25,7 @@ import { buildApiModel } from './intermediate-representation/build.js'; import { allOperations, type ApiModel } from './intermediate-representation/model.js'; import { normalizeSwagger2 } from './intermediate-representation/normalize-swagger2.js'; import { loadSpec } from './loader.js'; +import { resolveModelPagination, type ModelPagination } from './pagination.js'; import type { GenerateClientOptions, GenerateClientResult } from './types.js'; /** diff --git a/packages/client-generator/src/types.ts b/packages/client-generator/src/types.ts index cedbcecc07..8fbb07270e 100644 --- a/packages/client-generator/src/types.ts +++ b/packages/client-generator/src/types.ts @@ -1,7 +1,7 @@ import type { Config as RedoclyConfig, Oas3Definition, detectSpec } from '@redocly/openapi-core'; -import type { PaginationConfig } from './emitters/pagination.js'; import type { ArgsStyle, CustomGenerator, OutputMode } from './generators/types.js'; +import type { PaginationConfig } from './pagination.js'; export type GenerateClientOptions = { /** Path or URL to the OpenAPI description (or an `apis:` alias from `redocly.yaml`). */ From 7ea1a6747ad5b7eeed00f31367f3c6175f6964bd Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 22:10:41 +0300 Subject: [PATCH 23/35] refactor: split the python generator into the ADR-0020 stage files --- .../__tests__/language-dogfooding.test.ts | 26 +- .../src/generators/python/client.ts | 117 +++ .../src/generators/python/descriptor.ts | 46 ++ .../src/generators/python/index.ts | 708 +----------------- .../src/generators/python/models.ts | 236 ++++++ .../src/generators/python/naming.ts | 38 + .../src/generators/python/operations.ts | 141 ++++ .../src/generators/python/pagination.ts | 119 +++ .../src/generators/python/types.ts | 45 ++ 9 files changed, 771 insertions(+), 705 deletions(-) create mode 100644 packages/client-generator/src/generators/python/client.ts create mode 100644 packages/client-generator/src/generators/python/descriptor.ts create mode 100644 packages/client-generator/src/generators/python/models.ts create mode 100644 packages/client-generator/src/generators/python/naming.ts create mode 100644 packages/client-generator/src/generators/python/operations.ts create mode 100644 packages/client-generator/src/generators/python/pagination.ts create mode 100644 packages/client-generator/src/generators/python/types.ts diff --git a/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts b/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts index be77334bd5..adc96fc46e 100644 --- a/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts +++ b/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -16,18 +16,22 @@ const SHARED_SPECIFIERS = [ '../types.js', // the generator contract ]; -describe.each(['python', 'go', 'php'])('%s/index.ts dogfooding invariant', (language) => { +describe.each(['python', 'go', 'php'])('%s folder dogfooding invariant', (language) => { it('imports only what the authoring skill offers to any custom generator', () => { - const source = readFileSync( - resolve(dirname(fileURLToPath(import.meta.url)), '..', language, 'index.ts'), - 'utf-8' - ); + const folder = resolve(dirname(fileURLToPath(import.meta.url)), '..', language); + const stageFiles = readdirSync(folder).filter((name) => name.endsWith('.ts')); + expect(stageFiles.length).toBeGreaterThan(0); // A generator's sharing tiers (ADR-0020): the neutral toolkit, its OWN language - // printer — never another language's — the runtime sources, and the contract. + // printer — never another language's — the runtime sources, the contract, and + // its own stage files. const allowed = new Set([...SHARED_SPECIFIERS, `../../printers/${language}.js`]); - const specifiers = [...source.matchAll(/from '([^']+)'/g)].map((match) => match[1]); - expect(specifiers.length).toBeGreaterThan(0); - const violations = specifiers.filter((specifier) => !allowed.has(specifier)); - expect(violations).toEqual([]); + for (const name of stageFiles) { + const source = readFileSync(resolve(folder, name), 'utf-8'); + const specifiers = [...source.matchAll(/from '([^']+)'/g)].map((match) => match[1]); + const violations = specifiers.filter( + (specifier) => !allowed.has(specifier) && !/^\.\/[a-z-]+\.js$/.test(specifier) + ); + expect(violations, name).toEqual([]); + } }); }); diff --git a/packages/client-generator/src/generators/python/client.ts b/packages/client-generator/src/generators/python/client.ts new file mode 100644 index 0000000000..be9d6d4b2a --- /dev/null +++ b/packages/client-generator/src/generators/python/client.ts @@ -0,0 +1,117 @@ +// The `client` stage: the `Servers` helper class and the `Client`/`AsyncClient` +// classes that assemble the per-operation methods. + +import { + identifierFor, + jsonSuccessSchema, + paginationItemSchema, + serverUrlParts, + sseResponse, + type DateType, +} from '../../authoring/index.js'; +import type { ApiModel, ServerModel } from '../../intermediate-representation/model.js'; +import type { PythonPrinter } from '../../printers/python.js'; +import { fieldName, naming, operationIdents, PY } from './naming.js'; +import { writeMethod } from './operations.js'; +import { writePaginationWrappers } from './pagination.js'; +import { pythonType } from './types.js'; + +/** The server URL as a Python expression: literals concatenated with declared-variable args. */ +function serverUrlExpression(server: ServerModel): string { + const parts = serverUrlParts(server).map((part) => + part.kind === 'literal' ? naming.string(part.value) : fieldName(part.name).python + ); + return parts.join(' + '); +} + +/** One static method per declared server; server variables become keyword arguments. */ +export function writePythonServers(printer: PythonPrinter, model: ApiModel): void { + const servers = model.servers ?? []; + if (servers.length === 0) return; + const usedNames = new Set(); + printer.block('class Servers:', () => { + printer.line( + '"""The declared servers; variables default to the values from the description."""' + ); + printer.blank(); + servers.forEach((server, index) => { + let name = identifierFor(server.description ?? `server${index + 1}`, { + style: 'snake', + reserved: PY, + }); + if (usedNames.has(name)) name = `${name}_${index + 1}`; + usedNames.add(name); + const params = server.variables.map( + (variable) => `${fieldName(variable.name).python}: str = ${naming.string(variable.default)}` + ); + if (index > 0) printer.blank(); + printer.line('@staticmethod'); + printer.block(`def ${name}(${params.join(', ')}) -> str:`, () => { + printer.line(`return ${serverUrlExpression(server)}`); + }); + }); + }); + printer.blank(); +} + +export function writeClientClass( + printer: PythonPrinter, + model: ApiModel, + errorMode: 'throw' | 'result', + isAsync: boolean, + paginationSpecs: Map | undefined>, + serverUrl: string, + dateType: DateType +): void { + const name = isAsync ? 'AsyncClient' : 'Client'; + const httpType = isAsync ? 'httpx.AsyncClient' : 'httpx.Client'; + printer.block(`class ${name}:`, () => { + printer.doc(`${isAsync ? 'Async ' : ''}client for ${model.title} (${model.version}).`); + printer.block( + `def __init__(self, server_url: str = ${naming.string(serverUrl)}, *, ` + + 'auth: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, ' + + 'timeout: Optional[float] = None, retry: Optional[Dict[str, Any]] = None, ' + + 'middleware: Optional[List[Any]] = None, idempotency_key: Any = None, ' + + `http_client: Optional[${httpType}] = None) -> None:`, + () => { + printer.line('self._server_url = server_url'); + printer.line('self._auth = auth or {}'); + printer.line('self._config: Dict[str, Any] = {'); + printer.indent(() => { + printer.line('"headers": headers or {},'); + printer.line('"timeout": timeout,'); + printer.line('"retry": retry or {},'); + printer.line('"middleware": middleware or [],'); + printer.line('"idempotency_key": idempotency_key,'); + }); + printer.line('}'); + printer.line(`self._http = http_client or ${httpType}()`); + } + ); + printer.blank(); + for (const { op, ident } of operationIdents(model)) { + writeMethod(printer, op, ident, errorMode, isAsync, dateType); + if (sseResponse(op) === undefined && (op.successResponseHeaders?.length ?? 0) > 0) { + writeMethod(printer, op, ident, errorMode, isAsync, dateType, model, true); + } + const spec = paginationSpecs.get(ident); + if (spec !== undefined) { + const success = jsonSuccessSchema(op); + const element = paginationItemSchema( + success, + typeof spec.items === 'string' ? spec.items : undefined, + model + ); + writePaginationWrappers( + printer, + op, + ident, + isAsync, + element === undefined ? 'Any' : pythonType(element, dateType), + dateType + ); + } + } + }); + printer.blank(); +} diff --git a/packages/client-generator/src/generators/python/descriptor.ts b/packages/client-generator/src/generators/python/descriptor.ts new file mode 100644 index 0000000000..209d310c73 --- /dev/null +++ b/packages/client-generator/src/generators/python/descriptor.ts @@ -0,0 +1,46 @@ +// The `descriptor` stage: the wire-shape literals the embedded runtime routes by — +// pagination specs, envelope-header coerce specs, and Python data literals. + +import { + headerCoerceType, + identifierFor, + type NeutralPaginationRule, +} from '../../authoring/index.js'; +import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; +import { naming, PY } from './naming.js'; + +/** JSON → Python literal (dicts/lists/strings/numbers/bools/None). */ +export function pythonLiteral(value: unknown): string { + return naming.literal(value); +} + +/** The resolved pagination rule mapped to the snake_case spec dict the embedded + * Python runtime consumes. */ +export function paginationSpec( + rule: NeutralPaginationRule | undefined +): Record | undefined { + if (rule === undefined) return undefined; + return { + style: rule.style, + ...(rule.param !== undefined ? { param: rule.param } : {}), + ...(rule.nextCursor !== undefined ? { next_cursor: rule.nextCursor } : {}), + ...(rule.hasMore !== undefined ? { has_more: rule.hasMore } : {}), + ...(rule.limitParam !== undefined ? { limit_param: rule.limitParam } : {}), + ...(rule.items !== undefined ? { items: rule.items } : {}), + }; +} + +/** Declared response headers as runtime coerce specs: `("wire-name", "snake_key", "type")`. */ +export function envelopeHeaderSpecs(op: OperationModel, model: ApiModel): string { + const used = new Set(); + const specs = (op.successResponseHeaders ?? []).map((header) => { + const base = identifierFor(header.name, { style: 'snake', reserved: PY }); + let key = base; + let suffix = 2; + while (used.has(key)) key = `${base}_${suffix++}`; + used.add(key); + const type = headerCoerceType(header.schema, model); + return `(${naming.string(header.name)}, ${naming.string(key)}, ${naming.string(type)})`; + }); + return `[${specs.join(', ')}]`; +} diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index c6b3440b59..af38d40d7b 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -2,96 +2,25 @@ // authored the way the AGENTS.md skill teaches users' agents to author theirs: // with the language-neutral toolkit only (Printer + schema/naming helpers). // A guard test pins that this module never imports the TS emitter toolkit. +// One file per pipeline stage (ADR-0020); this entry assembles them. -import { - type NeutralPaginationRule, - renderReferencePage, - discriminatorCases, - enumValues, - flattenAllOf, - headerCoerceType, - identifierFor, - isNullable, - RESERVED_WORDS, - uniqueIdentifiers, - unwrapNullable, - type DateType, - isMultipartBody, - jsonSuccessSchema, - sseResponse, - serverUrlParts, - securityRequirements, - paginationItemSchema, -} from '../../authoring/index.js'; +import { identifierFor, renderReferencePage, securityRequirements } 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 { OperationModel } from '../../intermediate-representation/model.js'; import { PythonPrinter } from '../../printers/python.js'; import type { CodeSample, Generator, GeneratorOptionsSchema, SampleContext } from '../types.js'; +import { writeClientClass, writePythonServers } from './client.js'; +import { paginationSpec, pythonLiteral } from './descriptor.js'; +import { + discriminatorRegistrations, + pydanticDiscriminators, + renderPythonModels, + type PythonModels, +} from './models.js'; +import { operationIdents, PY } from './naming.js'; -const PY = RESERVED_WORDS.python; - -// Naming delegates to the printer — one implementation, used here and by any ejected copy. -const naming = new PythonPrinter(); - -/** A named schema's Python class name. */ -function className(name: string): string { - return naming.typeName(name); -} - -/** A field/parameter name, with the wire name preserved when sanitization renames it. */ -function fieldName(name: string): { python: string; renamed: boolean } { - const { identifier, renamed } = naming.memberName(name); - return { python: identifier, renamed }; -} - -/** The Python type annotation for a schema (anonymous complex shapes collapse to Any-ish). */ -export function pythonType(schema: SchemaModel, dateType: DateType = 'string'): string { - if (isNullable(schema)) { - return `Optional[${pythonType(unwrapNullable(schema), dateType)}]`; - } - switch (schema.kind) { - case 'scalar': - // `dateType: Date` annotates date/date-time as stdlib objects; `_decode.py` - // converts them from and to ISO strings on the wire. - if (dateType === 'Date' && schema.scalar === 'string') { - if (schema.metadata?.format === 'date-time') return 'datetime'; - if (schema.metadata?.format === 'date') return 'date'; - } - return { string: 'str', integer: 'int', number: 'float', boolean: 'bool' }[schema.scalar]; - case 'array': - return `List[${pythonType(schema.items, dateType)}]`; - case 'record': - return `Dict[str, ${pythonType(schema.value, dateType)}]`; - case 'ref': - return className(schema.name); - case 'literal': - return `Literal[${naming.literal(schema.value)}]`; - case 'enum': - // Anonymous (inline) enums keep the wire scalar; only NAMED enums get classes. - return { string: 'str', integer: 'int', number: 'float', boolean: 'bool' }[schema.scalar]; - case 'union': - return `Union[${schema.members.map((member) => pythonType(member, dateType)).join(', ')}]`; - case 'null': - return 'None'; - case 'omit': - // Python has no Omit; the base class is the honest annotation (readOnly - // fields are server-managed and simply absent on requests). - return className(schema.base); - case 'object': - case 'intersection': - case 'unknown': - return 'Any'; - } -} - -/** The model style the generator emits: plain dataclasses, or pydantic `BaseModel`s. */ -export type PythonModels = 'dataclass' | 'pydantic'; +export { renderPythonModels, type PythonModels } from './models.js'; +export { pythonType } from './types.js'; export const pythonOptions: GeneratorOptionsSchema = { type: 'object', @@ -106,615 +35,6 @@ export const pythonOptions: GeneratorOptionsSchema = { additionalProperties: false, }; -/** The wire property and value a union's discriminator mapping pins on one member class. */ -type DiscriminatorPin = { property: string; value: string }; - -/** - * Under `models: pydantic` the decoder hands a whole object tree to `model_validate`, so a - * union nested in a model is resolved by pydantic and never reaches the `DISCRIMINATORS` - * table. Pydantic resolves it correctly when the annotation carries the discriminator, which - * it accepts only if every member types that property as a `Literal` — and the mapping - * already pins one value per member. This pass works out which unions qualify: every member - * must declare the property, and no member may be pinned to two different values (a schema - * reused by two unions). - */ -function pydanticDiscriminators(model: ApiModel): { - pins: Map; - unions: Map; -} { - const pins = new Map(); - const conflicted = new Set(); - const candidates: Array<{ name: string; property: string; members: string[] }> = []; - for (const { name, schema } of model.schemas) { - const cases = discriminatorCases(schema, model); - if (cases === undefined) continue; - const declares = cases.cases.every( - (entry) => - flattenAllOf(entry.schema, model)?.properties.some( - (property) => property.name === cases.property - ) === true - ); - if (!declares) continue; - for (const entry of cases.cases) { - const existing = pins.get(entry.schemaName); - if (existing !== undefined && existing.value !== entry.value) { - conflicted.add(entry.schemaName); - continue; - } - pins.set(entry.schemaName, { property: cases.property, value: entry.value }); - } - candidates.push({ - name, - property: cases.property, - members: cases.cases.map((entry) => entry.schemaName), - }); - } - const unions = new Map(); - for (const candidate of candidates) { - if (candidate.members.some((member) => conflicted.has(member))) continue; - unions.set(candidate.name, fieldName(candidate.property).python); - } - for (const member of conflicted) pins.delete(member); - return { pins, unions }; -} - -/** - * The argument names every request method declares itself. A parameter named after one of - * them takes a suffixed binding instead, so the slot keeps its meaning. - */ -const METHOD_ARG_SLOTS = ['self', 'body', 'headers', 'timeout', 'retry', 'idempotency_key']; - -function writeDataclass( - printer: PythonPrinter, - name: string, - properties: PropertyModel[], - dateType: DateType, - models: PythonModels, - description?: string, - /** The discriminator value this class is mapped to, pinned as a `Literal` (pydantic). */ - pinned?: DiscriminatorPin -): void { - const pydantic = models === 'pydantic'; - if (!pydantic) printer.line('@dataclass'); - const header = pydantic ? `class ${className(name)}(BaseModel):` : `class ${className(name)}:`; - printer.block(header, () => { - printer.doc(description); - // A wire name that is not a legal field name travels as an alias, so the model - // accepts both spellings; without this, populating by field name would fail. - if (pydantic) { - printer.line('model_config = ConfigDict(populate_by_name=True)'); - printer.blank(); - } - // Required fields first — a dataclass field without a default may not follow one with. - const ordered = [ - ...properties.filter((property) => property.required), - ...properties.filter((property) => !property.required), - ]; - const fieldMap: Array<[string, string]> = []; - if (ordered.length === 0) printer.line('pass'); - for (const property of ordered) { - const { python, renamed } = fieldName(property.name); - if (renamed && !pydantic) fieldMap.push([python, property.name]); - const alias = renamed && pydantic ? `alias=${naming.string(property.name)}` : undefined; - const baseType = - pinned?.property === property.name - ? `Literal[${naming.literal(pinned.value)}]` - : pythonType(property.schema, dateType); - if (property.required) { - const value = alias === undefined ? '' : ` = Field(${alias})`; - printer.line(`${python}: ${baseType}${value}`); - } else { - const optional = baseType.startsWith('Optional[') ? baseType : `Optional[${baseType}]`; - const value = alias === undefined ? 'None' : `Field(default=None, ${alias})`; - printer.line(`${python}: ${optional} = ${value}`); - } - } - if (fieldMap.length > 0) { - printer.blank(); - printer.line('# Python field name -> wire (JSON) name, for (de)serialization.'); - const entries = fieldMap.map(([py, wire]) => `"${py}": ${naming.string(wire)}`).join(', '); - printer.line(`_field_map: ClassVar[Dict[str, str]] = {${entries}}`); - } - }); - printer.blank(); - printer.blank(); -} - -/** Render every named schema: Enum classes, dataclasses (allOf flattened), union aliases. */ -export function renderPythonModels( - model: ApiModel, - dateType: DateType = 'string', - models: PythonModels = 'dataclass' -): string { - const printer = new PythonPrinter(); - const { pins, unions } = - models === 'pydantic' - ? pydanticDiscriminators(model) - : { pins: new Map(), unions: new Map() }; - printer.line('from __future__ import annotations'); - printer.blank(); - if (models === 'dataclass') printer.line('from dataclasses import dataclass'); - printer.line('from enum import Enum'); - // `ClassVar` types the `_field_map` of a dataclass model, which pydantic mode - // replaces with field aliases — importing it there would be an unused import. - const typingNames = [ - 'Any', - 'AsyncIterator', - 'Dict', - 'Iterator', - 'List', - 'Literal', - 'Optional', - 'Tuple', - 'Union', - ]; - if (models === 'dataclass') typingNames.splice(2, 0, 'ClassVar'); - if (unions.size > 0) typingNames.unshift('Annotated'); - printer.line(`from typing import ${typingNames.join(', ')}`); - if (models === 'pydantic') printer.line('from pydantic import BaseModel, ConfigDict, Field'); - // Only under `dateType: Date` — an unused import in every other client would be noise. - if (dateType === 'Date') printer.line('from datetime import date, datetime'); - printer.blank(); - printer.blank(); - - const aliases: Array<() => void> = []; - for (const { name, schema } of model.schemas) { - const asEnum = enumValues(schema); - if (asEnum !== undefined) { - const base = asEnum.scalar === 'string' ? 'str, Enum' : 'int, Enum'; - printer.block(`class ${className(name)}(${base}):`, () => { - printer.doc(schema.description); - asEnum.values.forEach((value, index) => { - printer.line(`${asEnum.memberNames[index]} = ${naming.literal(value)}`); - }); - }); - printer.blank(); - printer.blank(); - continue; - } - if (schema.kind === 'object' || schema.kind === 'intersection') { - const flat = flattenAllOf(schema, model); - if (flat !== undefined) { - writeDataclass( - printer, - name, - flat.properties, - dateType, - models, - flat.description ?? schema.description, - pins.get(name) - ); - continue; - } - } - // Everything else (unions, scalar aliases, records) becomes a module-level alias, - // emitted AFTER the classes it references so the assignment evaluates. - aliases.push(() => { - const cases = discriminatorCases(schema, model); - if (cases !== undefined) { - const table = cases.cases - .map((entry) => `${entry.value} -> ${className(entry.schemaName)}`) - .join(', '); - printer.line(`# Discriminated by "${cases.property}": ${table}`); - } - const field = unions.get(name); - const union = - field === undefined - ? pythonType(schema, dateType) - : `Annotated[${pythonType(schema, dateType)}, Field(discriminator=${naming.string(field)})]`; - printer.line(`${className(name)} = ${union}`); - printer.blank(); - }); - } - for (const emit of aliases) emit(); - return printer.toString(); -} - -/** The server URL as a Python expression: literals concatenated with declared-variable args. */ -function serverUrlExpression(server: ServerModel): string { - const parts = serverUrlParts(server).map((part) => - part.kind === 'literal' ? naming.string(part.value) : fieldName(part.name).python - ); - return parts.join(' + '); -} - -/** One static method per declared server; server variables become keyword arguments. */ -function writePythonServers(printer: PythonPrinter, model: ApiModel): void { - const servers = model.servers ?? []; - if (servers.length === 0) return; - const usedNames = new Set(); - printer.block('class Servers:', () => { - printer.line( - '"""The declared servers; variables default to the values from the description."""' - ); - printer.blank(); - servers.forEach((server, index) => { - let name = identifierFor(server.description ?? `server${index + 1}`, { - style: 'snake', - reserved: PY, - }); - if (usedNames.has(name)) name = `${name}_${index + 1}`; - usedNames.add(name); - const params = server.variables.map( - (variable) => `${fieldName(variable.name).python}: str = ${naming.string(variable.default)}` - ); - if (index > 0) printer.blank(); - printer.line('@staticmethod'); - printer.block(`def ${name}(${params.join(', ')}) -> str:`, () => { - printer.line(`return ${serverUrlExpression(server)}`); - }); - }); - }); - printer.blank(); -} - -/** - * `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) => `${naming.string(entry.value)}: ${className(entry.schemaName)}`) - .join(', '); - lines.push( - `DISCRIMINATORS[${className(name)}] = (${naming.string(cases.property)}, {${mapping}})` - ); - } - return lines; -} - -/** JSON → Python literal (dicts/lists/strings/numbers/bools/None). */ -function pythonLiteral(value: unknown): string { - return naming.literal(value); -} - -/** Every operation with its collision-free snake_case Python method name. */ -function operationIdents(model: ApiModel): Array<{ op: OperationModel; ident: string }> { - const operations = model.services.flatMap((service) => service.operations); - const idents = uniqueIdentifiers( - operations.map((op) => op.name), - { style: 'snake', reserved: PY } - ); - return operations.map((op, index) => ({ op, ident: idents[index] })); -} - -/** The resolved pagination rule mapped to the snake_case spec dict the embedded - * Python runtime consumes. */ -function paginationSpec( - rule: NeutralPaginationRule | undefined -): Record | undefined { - if (rule === undefined) return undefined; - return { - style: rule.style, - ...(rule.param !== undefined ? { param: rule.param } : {}), - ...(rule.nextCursor !== undefined ? { next_cursor: rule.nextCursor } : {}), - ...(rule.hasMore !== undefined ? { has_more: rule.hasMore } : {}), - ...(rule.limitParam !== undefined ? { limit_param: rule.limitParam } : {}), - ...(rule.items !== undefined ? { items: rule.items } : {}), - }; -} - -/** Declared response headers as runtime coerce specs: `("wire-name", "snake_key", "type")`. */ -function envelopeHeaderSpecs(op: OperationModel, model: ApiModel): string { - const used = new Set(); - const specs = (op.successResponseHeaders ?? []).map((header) => { - const base = identifierFor(header.name, { style: 'snake', reserved: PY }); - let key = base; - let suffix = 2; - while (used.has(key)) key = `${base}_${suffix++}`; - used.add(key); - const type = headerCoerceType(header.schema, model); - return `(${naming.string(header.name)}, ${naming.string(key)}, ${naming.string(type)})`; - }); - return `[${specs.join(', ')}]`; -} - -function writeMethod( - printer: PythonPrinter, - op: OperationModel, - ident: string, - errorMode: 'throw' | 'result', - isAsync: boolean, - dateType: DateType, - model?: ApiModel, - envelope = false -): void { - // Every parameter is a separate argument, so path and query names share one namespace - // with the slots this method declares itself. `uniqueIdentifiers` moves a repeat aside - // (`id`, `id_2`) — a description may legally use one name in two locations, and a - // signature that declared it twice would not even parse. - const argNames = uniqueIdentifiers( - [...op.pathParams, ...op.queryParams].map((param) => param.name), - { style: 'snake', reserved: PY, taken: METHOD_ARG_SLOTS } - ); - const pathArgs = op.pathParams.map((param, index) => ({ param, python: argNames[index] })); - const queryArgs = op.queryParams.map((param, index) => ({ - param, - python: argNames[op.pathParams.length + index], - })); - const positional = pathArgs.map( - ({ param, python }) => `${python}: ${pythonType(param.schema, dateType)}` - ); - const bodyArg = op.requestBody ? [`body: ${pythonType(op.requestBody.schema, dateType)}`] : []; - const kwargs = [ - ...queryArgs.map(({ param, python }) => { - const annotation = pythonType(param.schema, dateType); - const optional = annotation.startsWith('Optional[') ? annotation : `Optional[${annotation}]`; - return `${python}: ${optional} = None`; - }), - 'headers: Optional[Dict[str, str]] = None', - 'timeout: Optional[float] = None', - 'retry: Optional[Dict[str, Any]] = None', - 'idempotency_key: Any = None', - ]; - const success = jsonSuccessSchema(op); - const sse = sseResponse(op); - const returns = envelope - ? `Envelope[${success === undefined ? 'None' : pythonType(success, dateType)}]` - : sse !== undefined - ? `${isAsync ? 'AsyncIterator' : 'Iterator'}[ServerSentEvent]` - : errorMode === 'result' - ? 'Result' - : success === undefined - ? 'None' - : pythonType(success, dateType); - // Streaming methods are plain defs returning an (async) iterator — an `async def` - // would force awaiting the call before iterating it. - const prefix = isAsync && sse === undefined ? 'async def' : 'def'; - const awaitKw = isAsync ? 'await ' : ''; - const sendFn = isAsync ? 'send_async' : 'send'; - const signature = ['self', ...positional, ...bodyArg, '*', ...kwargs].join(', '); - const defName = envelope ? `${ident}_with_headers` : ident; - printer.block(`${prefix} ${defName}(${signature}) -> ${returns}:`, () => { - printer.doc( - envelope - ? `Like ${ident}(), returning an Envelope with the declared response headers.` - : op.summary - ); - printer.line(`op = _OPERATIONS["${ident}"]`); - printer.line('auth_headers, auth_query = resolve_auth(op.get("security") or [], self._auth)'); - printer.line('params: Dict[str, Any] = dict(auth_query)'); - for (const { param, python } of queryArgs) { - printer.block(`if ${python} is not None:`, () => { - printer.line(`params[${naming.string(param.name)}] = encode(${python})`); - }); - } - const pathDict = pathArgs - .map(({ param, python }) => `${naming.string(param.name)}: ${python}`) - .join(', '); - printer.line(`url = build_url(self._server_url, op["path"], {${pathDict}})`); - if (sse !== undefined) { - const dataKind = sse.schema !== undefined && sse.schema.kind !== 'unknown' ? 'json' : 'text'; - printer.block('def _open(extra_headers: Dict[str, str]):', () => { - printer.line( - 'return self._http.stream(op["method"], url, ' + - 'headers={**auth_headers, **(headers or {}), **extra_headers}, params=params, timeout=timeout)' - ); - }); - printer.line(`return ${isAsync ? 'aiter_sse' : 'iter_sse'}(_open, data_kind="${dataKind}")`); - return; - } - if (isMultipartBody(op)) printer.line('form_data, form_files = to_multipart(body)'); - const bodyKw = op.requestBody - ? isMultipartBody(op) - ? ', data=form_data, files=form_files' - : ', json_body=encode(body)' - : ''; - printer.line( - `response = ${awaitKw}${sendFn}(self._http, self._config, op, url, method=op["method"], ` + - `headers={**auth_headers, **(headers or {})}, params=params${bodyKw}, ` + - 'timeout=timeout, retry=retry, idempotency_key=idempotency_key)' - ); - const decoded = - success === undefined - ? 'None' - : `decode(${pythonType(success, dateType)}, _safe_json(response))`; - if (envelope) { - printer.block('if not response.is_success:', () => { - printer.line( - 'raise ApiError(url, response.status_code, response.reason_phrase, _safe_json(response))' - ); - }); - printer.line( - `return Envelope(data=${decoded}, headers=read_envelope_headers(response, ${envelopeHeaderSpecs(op, model!)}), response=response)` - ); - } else if (errorMode === 'result') { - printer.block('if not response.is_success:', () => { - printer.line('return Result(data=None, error=_safe_json(response), response=response)'); - }); - printer.line(`return Result(data=${decoded}, error=None, response=response)`); - } else { - printer.block('if not response.is_success:', () => { - printer.line( - 'raise ApiError(url, response.status_code, response.reason_phrase, _safe_json(response))' - ); - }); - printer.line(success === undefined ? 'return None' : `return ${decoded}`); - } - }); - printer.blank(); -} - -/** `_pages` / `_items` iterator methods for a paginated operation. */ -function writePaginationWrappers( - printer: PythonPrinter, - op: OperationModel, - ident: string, - isAsync: boolean, - itemType: string, - dateType: DateType -): void { - const success = jsonSuccessSchema(op); - const pageType = success === undefined ? 'Any' : pythonType(success, dateType); - // The iterators take the same arguments as the operation itself, computed the same way, - // so a name the method moved aside (`id_2`) is the same name here — copying a call from - // one to the other has to keep working. Path values are substituted, not dropped. - const argNames = uniqueIdentifiers( - [...op.pathParams, ...op.queryParams].map((param) => param.name), - { style: 'snake', reserved: PY, taken: METHOD_ARG_SLOTS } - ); - const pathArgs = op.pathParams.map((param, index) => ({ param, python: argNames[index] })); - const queryArgs = op.queryParams.map((param, index) => ({ - param, - python: argNames[op.pathParams.length + index], - })); - const positional = pathArgs.map( - ({ param, python }) => `${python}: ${pythonType(param.schema, dateType)}` - ); - const kwargs = [ - ...queryArgs.map(({ param, python }) => { - const annotation = pythonType(param.schema, dateType); - const optional = annotation.startsWith('Optional[') ? annotation : `Optional[${annotation}]`; - return `${python}: ${optional} = None`; - }), - 'headers: Optional[Dict[str, str]] = None', - 'timeout: Optional[float] = None', - 'retry: Optional[Dict[str, Any]] = None', - ]; - const signature = ['self', ...positional, '*', ...kwargs].join(', '); - const iterType = isAsync ? 'AsyncIterator' : 'Iterator'; - const pagesFn = isAsync ? 'aiter_pages' : 'iter_pages'; - const itemsFn = isAsync ? 'aiter_items' : 'iter_items'; - - const writeCallClosure = () => { - printer.line('base: Dict[str, Any] = {}'); - for (const { param, python } of queryArgs) { - printer.block(`if ${python} is not None:`, () => { - printer.line(`base[${naming.string(param.name)}] = encode(${python})`); - }); - } - const prefix = isAsync ? 'async def' : 'def'; - const awaitKw = isAsync ? 'await ' : ''; - printer.block(`${prefix} _page(page_params: Dict[str, Any]) -> Tuple[Any, Any]:`, () => { - printer.line('auth_headers, auth_query = resolve_auth(op.get("security") or [], self._auth)'); - const pathDict = pathArgs - .map(({ param, python }) => `${naming.string(param.name)}: ${python}`) - .join(', '); - printer.line(`url = build_url(self._server_url, op["path"], {${pathDict}})`); - printer.line( - `response = ${awaitKw}${isAsync ? 'send_async' : 'send'}(self._http, self._config, op, url, method=op["method"], ` + - 'headers={**auth_headers, **(headers or {})}, params={**page_params, **auth_query}, ' + - 'timeout=timeout, retry=retry)' - ); - printer.block('if not response.is_success:', () => { - printer.line( - 'raise ApiError(url, response.status_code, response.reason_phrase, _safe_json(response))' - ); - }); - printer.line('return _safe_json(response), response'); - }); - }; - - // pages: raw page JSON decoded into the page model per page. - if (isAsync) { - printer.block(`async def ${ident}_pages(${signature}) -> ${iterType}[${pageType}]:`, () => { - printer.line(`op = _OPERATIONS["${ident}"]`); - writeCallClosure(); - printer.block(`async for page in ${pagesFn}(_page, op["pagination"], base):`, () => { - printer.line(pageType === 'Any' ? 'yield page' : `yield decode(${pageType}, page)`); - }); - }); - printer.blank(); - printer.block(`async def ${ident}_items(${signature}) -> ${iterType}[${itemType}]:`, () => { - printer.line(`op = _OPERATIONS["${ident}"]`); - writeCallClosure(); - printer.block(`async for item in ${itemsFn}(_page, op["pagination"], base):`, () => { - printer.line(itemType === 'Any' ? 'yield item' : `yield decode(${itemType}, item)`); - }); - }); - } else { - printer.block(`def ${ident}_pages(${signature}) -> ${iterType}[${pageType}]:`, () => { - printer.line(`op = _OPERATIONS["${ident}"]`); - writeCallClosure(); - printer.line( - pageType === 'Any' - ? `return ${pagesFn}(_page, op["pagination"], base)` - : `return (decode(${pageType}, page) for page in ${pagesFn}(_page, op["pagination"], base))` - ); - }); - printer.blank(); - printer.block(`def ${ident}_items(${signature}) -> ${iterType}[${itemType}]:`, () => { - printer.line(`op = _OPERATIONS["${ident}"]`); - writeCallClosure(); - printer.line( - itemType === 'Any' - ? `return ${itemsFn}(_page, op["pagination"], base)` - : `return (decode(${itemType}, item) for item in ${itemsFn}(_page, op["pagination"], base))` - ); - }); - } - printer.blank(); -} - -function writeClientClass( - printer: PythonPrinter, - model: ApiModel, - errorMode: 'throw' | 'result', - isAsync: boolean, - paginationSpecs: Map | undefined>, - serverUrl: string, - dateType: DateType -): void { - const name = isAsync ? 'AsyncClient' : 'Client'; - const httpType = isAsync ? 'httpx.AsyncClient' : 'httpx.Client'; - printer.block(`class ${name}:`, () => { - printer.doc(`${isAsync ? 'Async ' : ''}client for ${model.title} (${model.version}).`); - printer.block( - `def __init__(self, server_url: str = ${naming.string(serverUrl)}, *, ` + - 'auth: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, ' + - 'timeout: Optional[float] = None, retry: Optional[Dict[str, Any]] = None, ' + - 'middleware: Optional[List[Any]] = None, idempotency_key: Any = None, ' + - `http_client: Optional[${httpType}] = None) -> None:`, - () => { - printer.line('self._server_url = server_url'); - printer.line('self._auth = auth or {}'); - printer.line('self._config: Dict[str, Any] = {'); - printer.indent(() => { - printer.line('"headers": headers or {},'); - printer.line('"timeout": timeout,'); - printer.line('"retry": retry or {},'); - printer.line('"middleware": middleware or [],'); - printer.line('"idempotency_key": idempotency_key,'); - }); - printer.line('}'); - printer.line(`self._http = http_client or ${httpType}()`); - } - ); - printer.blank(); - for (const { op, ident } of operationIdents(model)) { - writeMethod(printer, op, ident, errorMode, isAsync, dateType); - if (sseResponse(op) === undefined && (op.successResponseHeaders?.length ?? 0) > 0) { - writeMethod(printer, op, ident, errorMode, isAsync, dateType, model, true); - } - const spec = paginationSpecs.get(ident); - if (spec !== undefined) { - const success = jsonSuccessSchema(op); - const element = paginationItemSchema( - success, - typeof spec.items === 'string' ? spec.items : undefined, - model - ); - writePaginationWrappers( - printer, - op, - ident, - isAsync, - element === undefined ? 'Any' : pythonType(element, dateType), - dateType - ); - } - } - }); - printer.blank(); -} - /** * The output path with an IMPORTABLE module name. The `--output` stem follows the * TypeScript convention (`openapi.client.ts`), and `openapi.client.py` cannot be diff --git a/packages/client-generator/src/generators/python/models.ts b/packages/client-generator/src/generators/python/models.ts new file mode 100644 index 0000000000..f2cdf022dd --- /dev/null +++ b/packages/client-generator/src/generators/python/models.ts @@ -0,0 +1,236 @@ +// The `models` stage: named schemas as Enum classes, dataclasses/pydantic models +// (allOf flattened), union aliases, and the decoder's discriminator registrations. + +import { + discriminatorCases, + enumValues, + flattenAllOf, + type DateType, +} from '../../authoring/index.js'; +import type { ApiModel, PropertyModel } from '../../intermediate-representation/model.js'; +import { PythonPrinter } from '../../printers/python.js'; +import { className, fieldName, naming } from './naming.js'; +import { pythonType } from './types.js'; + +/** The model style the generator emits: plain dataclasses, or pydantic `BaseModel`s. */ +export type PythonModels = 'dataclass' | 'pydantic'; + +/** The wire property and value a union's discriminator mapping pins on one member class. */ +export type DiscriminatorPin = { property: string; value: string }; + +/** + * Under `models: pydantic` the decoder hands a whole object tree to `model_validate`, so a + * union nested in a model is resolved by pydantic and never reaches the `DISCRIMINATORS` + * table. Pydantic resolves it correctly when the annotation carries the discriminator, which + * it accepts only if every member types that property as a `Literal` — and the mapping + * already pins one value per member. This pass works out which unions qualify: every member + * must declare the property, and no member may be pinned to two different values (a schema + * reused by two unions). + */ +export function pydanticDiscriminators(model: ApiModel): { + pins: Map; + unions: Map; +} { + const pins = new Map(); + const conflicted = new Set(); + const candidates: Array<{ name: string; property: string; members: string[] }> = []; + for (const { name, schema } of model.schemas) { + const cases = discriminatorCases(schema, model); + if (cases === undefined) continue; + const declares = cases.cases.every( + (entry) => + flattenAllOf(entry.schema, model)?.properties.some( + (property) => property.name === cases.property + ) === true + ); + if (!declares) continue; + for (const entry of cases.cases) { + const existing = pins.get(entry.schemaName); + if (existing !== undefined && existing.value !== entry.value) { + conflicted.add(entry.schemaName); + continue; + } + pins.set(entry.schemaName, { property: cases.property, value: entry.value }); + } + candidates.push({ + name, + property: cases.property, + members: cases.cases.map((entry) => entry.schemaName), + }); + } + const unions = new Map(); + for (const candidate of candidates) { + if (candidate.members.some((member) => conflicted.has(member))) continue; + unions.set(candidate.name, fieldName(candidate.property).python); + } + for (const member of conflicted) pins.delete(member); + return { pins, unions }; +} + +function writeDataclass( + printer: PythonPrinter, + name: string, + properties: PropertyModel[], + dateType: DateType, + models: PythonModels, + description?: string, + /** The discriminator value this class is mapped to, pinned as a `Literal` (pydantic). */ + pinned?: DiscriminatorPin +): void { + const pydantic = models === 'pydantic'; + if (!pydantic) printer.line('@dataclass'); + const header = pydantic ? `class ${className(name)}(BaseModel):` : `class ${className(name)}:`; + printer.block(header, () => { + printer.doc(description); + // A wire name that is not a legal field name travels as an alias, so the model + // accepts both spellings; without this, populating by field name would fail. + if (pydantic) { + printer.line('model_config = ConfigDict(populate_by_name=True)'); + printer.blank(); + } + // Required fields first — a dataclass field without a default may not follow one with. + const ordered = [ + ...properties.filter((property) => property.required), + ...properties.filter((property) => !property.required), + ]; + const fieldMap: Array<[string, string]> = []; + if (ordered.length === 0) printer.line('pass'); + for (const property of ordered) { + const { python, renamed } = fieldName(property.name); + if (renamed && !pydantic) fieldMap.push([python, property.name]); + const alias = renamed && pydantic ? `alias=${naming.string(property.name)}` : undefined; + const baseType = + pinned?.property === property.name + ? `Literal[${naming.literal(pinned.value)}]` + : pythonType(property.schema, dateType); + if (property.required) { + const value = alias === undefined ? '' : ` = Field(${alias})`; + printer.line(`${python}: ${baseType}${value}`); + } else { + const optional = baseType.startsWith('Optional[') ? baseType : `Optional[${baseType}]`; + const value = alias === undefined ? 'None' : `Field(default=None, ${alias})`; + printer.line(`${python}: ${optional} = ${value}`); + } + } + if (fieldMap.length > 0) { + printer.blank(); + printer.line('# Python field name -> wire (JSON) name, for (de)serialization.'); + const entries = fieldMap.map(([py, wire]) => `"${py}": ${naming.string(wire)}`).join(', '); + printer.line(`_field_map: ClassVar[Dict[str, str]] = {${entries}}`); + } + }); + printer.blank(); + printer.blank(); +} + +/** Render every named schema: Enum classes, dataclasses (allOf flattened), union aliases. */ +export function renderPythonModels( + model: ApiModel, + dateType: DateType = 'string', + models: PythonModels = 'dataclass' +): string { + const printer = new PythonPrinter(); + const { pins, unions } = + models === 'pydantic' + ? pydanticDiscriminators(model) + : { pins: new Map(), unions: new Map() }; + printer.line('from __future__ import annotations'); + printer.blank(); + if (models === 'dataclass') printer.line('from dataclasses import dataclass'); + printer.line('from enum import Enum'); + // `ClassVar` types the `_field_map` of a dataclass model, which pydantic mode + // replaces with field aliases — importing it there would be an unused import. + const typingNames = [ + 'Any', + 'AsyncIterator', + 'Dict', + 'Iterator', + 'List', + 'Literal', + 'Optional', + 'Tuple', + 'Union', + ]; + if (models === 'dataclass') typingNames.splice(2, 0, 'ClassVar'); + if (unions.size > 0) typingNames.unshift('Annotated'); + printer.line(`from typing import ${typingNames.join(', ')}`); + if (models === 'pydantic') printer.line('from pydantic import BaseModel, ConfigDict, Field'); + // Only under `dateType: Date` — an unused import in every other client would be noise. + if (dateType === 'Date') printer.line('from datetime import date, datetime'); + printer.blank(); + printer.blank(); + + const aliases: Array<() => void> = []; + for (const { name, schema } of model.schemas) { + const asEnum = enumValues(schema); + if (asEnum !== undefined) { + const base = asEnum.scalar === 'string' ? 'str, Enum' : 'int, Enum'; + printer.block(`class ${className(name)}(${base}):`, () => { + printer.doc(schema.description); + asEnum.values.forEach((value, index) => { + printer.line(`${asEnum.memberNames[index]} = ${naming.literal(value)}`); + }); + }); + printer.blank(); + printer.blank(); + continue; + } + if (schema.kind === 'object' || schema.kind === 'intersection') { + const flat = flattenAllOf(schema, model); + if (flat !== undefined) { + writeDataclass( + printer, + name, + flat.properties, + dateType, + models, + flat.description ?? schema.description, + pins.get(name) + ); + continue; + } + } + // Everything else (unions, scalar aliases, records) becomes a module-level alias, + // emitted AFTER the classes it references so the assignment evaluates. + aliases.push(() => { + const cases = discriminatorCases(schema, model); + if (cases !== undefined) { + const table = cases.cases + .map((entry) => `${entry.value} -> ${className(entry.schemaName)}`) + .join(', '); + printer.line(`# Discriminated by "${cases.property}": ${table}`); + } + const field = unions.get(name); + const union = + field === undefined + ? pythonType(schema, dateType) + : `Annotated[${pythonType(schema, dateType)}, Field(discriminator=${naming.string(field)})]`; + printer.line(`${className(name)} = ${union}`); + printer.blank(); + }); + } + for (const emit of aliases) emit(); + return printer.toString(); +} + +/** + * `DISCRIMINATORS[Pet] = ("petType", {"cat": Cat, ...})` registration lines, which `decode` + * dispatches through. A union whose annotation already carries the discriminator is left + * out: pydantic resolves it at any depth, and the `Literal` on each member makes the + * decoder's member probe exact. + */ +export function discriminatorRegistrations(model: ApiModel, annotated: Set): string[] { + const lines: string[] = []; + for (const { name, schema } of model.schemas) { + if (annotated.has(name)) continue; + const cases = discriminatorCases(schema, model); + if (cases === undefined) continue; + const mapping = cases.cases + .map((entry) => `${naming.string(entry.value)}: ${className(entry.schemaName)}`) + .join(', '); + lines.push( + `DISCRIMINATORS[${className(name)}] = (${naming.string(cases.property)}, {${mapping}})` + ); + } + return lines; +} diff --git a/packages/client-generator/src/generators/python/naming.ts b/packages/client-generator/src/generators/python/naming.ts new file mode 100644 index 0000000000..16cbd6db7a --- /dev/null +++ b/packages/client-generator/src/generators/python/naming.ts @@ -0,0 +1,38 @@ +// The `naming` stage: the shared printer/naming instance and the collision-free +// identifier derivations every other stage builds on. + +import { RESERVED_WORDS, uniqueIdentifiers } from '../../authoring/index.js'; +import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; +import { PythonPrinter } from '../../printers/python.js'; + +export const PY = RESERVED_WORDS.python; + +// Naming delegates to the printer — one implementation, used here and by any ejected copy. +export const naming = new PythonPrinter(); + +/** A named schema's Python class name. */ +export function className(name: string): string { + return naming.typeName(name); +} + +/** A field/parameter name, with the wire name preserved when sanitization renames it. */ +export function fieldName(name: string): { python: string; renamed: boolean } { + const { identifier, renamed } = naming.memberName(name); + return { python: identifier, renamed }; +} + +/** Every operation with its collision-free snake_case Python method name. */ +export function operationIdents(model: ApiModel): Array<{ op: OperationModel; ident: string }> { + const operations = model.services.flatMap((service) => service.operations); + const idents = uniqueIdentifiers( + operations.map((op) => op.name), + { style: 'snake', reserved: PY } + ); + return operations.map((op, index) => ({ op, ident: idents[index] })); +} + +/** + * The argument names every request method declares itself. A parameter named after one of + * them takes a suffixed binding instead, so the slot keeps its meaning. + */ +export const METHOD_ARG_SLOTS = ['self', 'body', 'headers', 'timeout', 'retry', 'idempotency_key']; diff --git a/packages/client-generator/src/generators/python/operations.ts b/packages/client-generator/src/generators/python/operations.ts new file mode 100644 index 0000000000..893c2f94ef --- /dev/null +++ b/packages/client-generator/src/generators/python/operations.ts @@ -0,0 +1,141 @@ +// The `operations` stage: one typed request method per operation (sync and async), +// with the optional `_with_headers` envelope variant. + +import { + isMultipartBody, + jsonSuccessSchema, + sseResponse, + uniqueIdentifiers, + type DateType, +} from '../../authoring/index.js'; +import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; +import type { PythonPrinter } from '../../printers/python.js'; +import { envelopeHeaderSpecs } from './descriptor.js'; +import { METHOD_ARG_SLOTS, naming, PY } from './naming.js'; +import { pythonType } from './types.js'; + +export function writeMethod( + printer: PythonPrinter, + op: OperationModel, + ident: string, + errorMode: 'throw' | 'result', + isAsync: boolean, + dateType: DateType, + model?: ApiModel, + envelope = false +): void { + // Every parameter is a separate argument, so path and query names share one namespace + // with the slots this method declares itself. `uniqueIdentifiers` moves a repeat aside + // (`id`, `id_2`) — a description may legally use one name in two locations, and a + // signature that declared it twice would not even parse. + const argNames = uniqueIdentifiers( + [...op.pathParams, ...op.queryParams].map((param) => param.name), + { style: 'snake', reserved: PY, taken: METHOD_ARG_SLOTS } + ); + const pathArgs = op.pathParams.map((param, index) => ({ param, python: argNames[index] })); + const queryArgs = op.queryParams.map((param, index) => ({ + param, + python: argNames[op.pathParams.length + index], + })); + const positional = pathArgs.map( + ({ param, python }) => `${python}: ${pythonType(param.schema, dateType)}` + ); + const bodyArg = op.requestBody ? [`body: ${pythonType(op.requestBody.schema, dateType)}`] : []; + const kwargs = [ + ...queryArgs.map(({ param, python }) => { + const annotation = pythonType(param.schema, dateType); + const optional = annotation.startsWith('Optional[') ? annotation : `Optional[${annotation}]`; + return `${python}: ${optional} = None`; + }), + 'headers: Optional[Dict[str, str]] = None', + 'timeout: Optional[float] = None', + 'retry: Optional[Dict[str, Any]] = None', + 'idempotency_key: Any = None', + ]; + const success = jsonSuccessSchema(op); + const sse = sseResponse(op); + const returns = envelope + ? `Envelope[${success === undefined ? 'None' : pythonType(success, dateType)}]` + : sse !== undefined + ? `${isAsync ? 'AsyncIterator' : 'Iterator'}[ServerSentEvent]` + : errorMode === 'result' + ? 'Result' + : success === undefined + ? 'None' + : pythonType(success, dateType); + // Streaming methods are plain defs returning an (async) iterator — an `async def` + // would force awaiting the call before iterating it. + const prefix = isAsync && sse === undefined ? 'async def' : 'def'; + const awaitKw = isAsync ? 'await ' : ''; + const sendFn = isAsync ? 'send_async' : 'send'; + const signature = ['self', ...positional, ...bodyArg, '*', ...kwargs].join(', '); + const defName = envelope ? `${ident}_with_headers` : ident; + printer.block(`${prefix} ${defName}(${signature}) -> ${returns}:`, () => { + printer.doc( + envelope + ? `Like ${ident}(), returning an Envelope with the declared response headers.` + : op.summary + ); + printer.line(`op = _OPERATIONS["${ident}"]`); + printer.line('auth_headers, auth_query = resolve_auth(op.get("security") or [], self._auth)'); + printer.line('params: Dict[str, Any] = dict(auth_query)'); + for (const { param, python } of queryArgs) { + printer.block(`if ${python} is not None:`, () => { + printer.line(`params[${naming.string(param.name)}] = encode(${python})`); + }); + } + const pathDict = pathArgs + .map(({ param, python }) => `${naming.string(param.name)}: ${python}`) + .join(', '); + printer.line(`url = build_url(self._server_url, op["path"], {${pathDict}})`); + if (sse !== undefined) { + const dataKind = sse.schema !== undefined && sse.schema.kind !== 'unknown' ? 'json' : 'text'; + printer.block('def _open(extra_headers: Dict[str, str]):', () => { + printer.line( + 'return self._http.stream(op["method"], url, ' + + 'headers={**auth_headers, **(headers or {}), **extra_headers}, params=params, timeout=timeout)' + ); + }); + printer.line(`return ${isAsync ? 'aiter_sse' : 'iter_sse'}(_open, data_kind="${dataKind}")`); + return; + } + if (isMultipartBody(op)) printer.line('form_data, form_files = to_multipart(body)'); + const bodyKw = op.requestBody + ? isMultipartBody(op) + ? ', data=form_data, files=form_files' + : ', json_body=encode(body)' + : ''; + printer.line( + `response = ${awaitKw}${sendFn}(self._http, self._config, op, url, method=op["method"], ` + + `headers={**auth_headers, **(headers or {})}, params=params${bodyKw}, ` + + 'timeout=timeout, retry=retry, idempotency_key=idempotency_key)' + ); + const decoded = + success === undefined + ? 'None' + : `decode(${pythonType(success, dateType)}, _safe_json(response))`; + if (envelope) { + printer.block('if not response.is_success:', () => { + printer.line( + 'raise ApiError(url, response.status_code, response.reason_phrase, _safe_json(response))' + ); + }); + printer.line( + `return Envelope(data=${decoded}, headers=read_envelope_headers(response, ${envelopeHeaderSpecs(op, model!)}), response=response)` + ); + } else if (errorMode === 'result') { + printer.block('if not response.is_success:', () => { + printer.line('return Result(data=None, error=_safe_json(response), response=response)'); + }); + printer.line(`return Result(data=${decoded}, error=None, response=response)`); + } else { + printer.block('if not response.is_success:', () => { + printer.line( + 'raise ApiError(url, response.status_code, response.reason_phrase, _safe_json(response))' + ); + }); + printer.line(success === undefined ? 'return None' : `return ${decoded}`); + } + }); + printer.blank(); +} diff --git a/packages/client-generator/src/generators/python/pagination.ts b/packages/client-generator/src/generators/python/pagination.ts new file mode 100644 index 0000000000..6a9904161f --- /dev/null +++ b/packages/client-generator/src/generators/python/pagination.ts @@ -0,0 +1,119 @@ +// The `pagination` stage: `_pages` / `_items` iterator methods for +// paginated operations, sync and async. + +import { jsonSuccessSchema, uniqueIdentifiers, type DateType } from '../../authoring/index.js'; +import type { OperationModel } from '../../intermediate-representation/model.js'; +import type { PythonPrinter } from '../../printers/python.js'; +import { METHOD_ARG_SLOTS, naming, PY } from './naming.js'; +import { pythonType } from './types.js'; + +/** `_pages` / `_items` iterator methods for a paginated operation. */ +export function writePaginationWrappers( + printer: PythonPrinter, + op: OperationModel, + ident: string, + isAsync: boolean, + itemType: string, + dateType: DateType +): void { + const success = jsonSuccessSchema(op); + const pageType = success === undefined ? 'Any' : pythonType(success, dateType); + // The iterators take the same arguments as the operation itself, computed the same way, + // so a name the method moved aside (`id_2`) is the same name here — copying a call from + // one to the other has to keep working. Path values are substituted, not dropped. + const argNames = uniqueIdentifiers( + [...op.pathParams, ...op.queryParams].map((param) => param.name), + { style: 'snake', reserved: PY, taken: METHOD_ARG_SLOTS } + ); + const pathArgs = op.pathParams.map((param, index) => ({ param, python: argNames[index] })); + const queryArgs = op.queryParams.map((param, index) => ({ + param, + python: argNames[op.pathParams.length + index], + })); + const positional = pathArgs.map( + ({ param, python }) => `${python}: ${pythonType(param.schema, dateType)}` + ); + const kwargs = [ + ...queryArgs.map(({ param, python }) => { + const annotation = pythonType(param.schema, dateType); + const optional = annotation.startsWith('Optional[') ? annotation : `Optional[${annotation}]`; + return `${python}: ${optional} = None`; + }), + 'headers: Optional[Dict[str, str]] = None', + 'timeout: Optional[float] = None', + 'retry: Optional[Dict[str, Any]] = None', + ]; + const signature = ['self', ...positional, '*', ...kwargs].join(', '); + const iterType = isAsync ? 'AsyncIterator' : 'Iterator'; + const pagesFn = isAsync ? 'aiter_pages' : 'iter_pages'; + const itemsFn = isAsync ? 'aiter_items' : 'iter_items'; + + const writeCallClosure = () => { + printer.line('base: Dict[str, Any] = {}'); + for (const { param, python } of queryArgs) { + printer.block(`if ${python} is not None:`, () => { + printer.line(`base[${naming.string(param.name)}] = encode(${python})`); + }); + } + const prefix = isAsync ? 'async def' : 'def'; + const awaitKw = isAsync ? 'await ' : ''; + printer.block(`${prefix} _page(page_params: Dict[str, Any]) -> Tuple[Any, Any]:`, () => { + printer.line('auth_headers, auth_query = resolve_auth(op.get("security") or [], self._auth)'); + const pathDict = pathArgs + .map(({ param, python }) => `${naming.string(param.name)}: ${python}`) + .join(', '); + printer.line(`url = build_url(self._server_url, op["path"], {${pathDict}})`); + printer.line( + `response = ${awaitKw}${isAsync ? 'send_async' : 'send'}(self._http, self._config, op, url, method=op["method"], ` + + 'headers={**auth_headers, **(headers or {})}, params={**page_params, **auth_query}, ' + + 'timeout=timeout, retry=retry)' + ); + printer.block('if not response.is_success:', () => { + printer.line( + 'raise ApiError(url, response.status_code, response.reason_phrase, _safe_json(response))' + ); + }); + printer.line('return _safe_json(response), response'); + }); + }; + + // pages: raw page JSON decoded into the page model per page. + if (isAsync) { + printer.block(`async def ${ident}_pages(${signature}) -> ${iterType}[${pageType}]:`, () => { + printer.line(`op = _OPERATIONS["${ident}"]`); + writeCallClosure(); + printer.block(`async for page in ${pagesFn}(_page, op["pagination"], base):`, () => { + printer.line(pageType === 'Any' ? 'yield page' : `yield decode(${pageType}, page)`); + }); + }); + printer.blank(); + printer.block(`async def ${ident}_items(${signature}) -> ${iterType}[${itemType}]:`, () => { + printer.line(`op = _OPERATIONS["${ident}"]`); + writeCallClosure(); + printer.block(`async for item in ${itemsFn}(_page, op["pagination"], base):`, () => { + printer.line(itemType === 'Any' ? 'yield item' : `yield decode(${itemType}, item)`); + }); + }); + } else { + printer.block(`def ${ident}_pages(${signature}) -> ${iterType}[${pageType}]:`, () => { + printer.line(`op = _OPERATIONS["${ident}"]`); + writeCallClosure(); + printer.line( + pageType === 'Any' + ? `return ${pagesFn}(_page, op["pagination"], base)` + : `return (decode(${pageType}, page) for page in ${pagesFn}(_page, op["pagination"], base))` + ); + }); + printer.blank(); + printer.block(`def ${ident}_items(${signature}) -> ${iterType}[${itemType}]:`, () => { + printer.line(`op = _OPERATIONS["${ident}"]`); + writeCallClosure(); + printer.line( + itemType === 'Any' + ? `return ${itemsFn}(_page, op["pagination"], base)` + : `return (decode(${itemType}, item) for item in ${itemsFn}(_page, op["pagination"], base))` + ); + }); + } + printer.blank(); +} diff --git a/packages/client-generator/src/generators/python/types.ts b/packages/client-generator/src/generators/python/types.ts new file mode 100644 index 0000000000..9e25c65cdb --- /dev/null +++ b/packages/client-generator/src/generators/python/types.ts @@ -0,0 +1,45 @@ +// The `types` stage: schema → Python type annotation. + +import { isNullable, unwrapNullable, type DateType } from '../../authoring/index.js'; +import type { SchemaModel } from '../../intermediate-representation/model.js'; +import { className, naming } from './naming.js'; + +/** The Python type annotation for a schema (anonymous complex shapes collapse to Any-ish). */ +export function pythonType(schema: SchemaModel, dateType: DateType = 'string'): string { + if (isNullable(schema)) { + return `Optional[${pythonType(unwrapNullable(schema), dateType)}]`; + } + switch (schema.kind) { + case 'scalar': + // `dateType: Date` annotates date/date-time as stdlib objects; `_decode.py` + // converts them from and to ISO strings on the wire. + if (dateType === 'Date' && schema.scalar === 'string') { + if (schema.metadata?.format === 'date-time') return 'datetime'; + if (schema.metadata?.format === 'date') return 'date'; + } + return { string: 'str', integer: 'int', number: 'float', boolean: 'bool' }[schema.scalar]; + case 'array': + return `List[${pythonType(schema.items, dateType)}]`; + case 'record': + return `Dict[str, ${pythonType(schema.value, dateType)}]`; + case 'ref': + return className(schema.name); + case 'literal': + return `Literal[${naming.literal(schema.value)}]`; + case 'enum': + // Anonymous (inline) enums keep the wire scalar; only NAMED enums get classes. + return { string: 'str', integer: 'int', number: 'float', boolean: 'bool' }[schema.scalar]; + case 'union': + return `Union[${schema.members.map((member) => pythonType(member, dateType)).join(', ')}]`; + case 'null': + return 'None'; + case 'omit': + // Python has no Omit; the base class is the honest annotation (readOnly + // fields are server-managed and simply absent on requests). + return className(schema.base); + case 'object': + case 'intersection': + case 'unknown': + return 'Any'; + } +} From 874ef15d3ce2b44d049e9f95c4ddae354e28ad70 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 22:16:51 +0300 Subject: [PATCH 24/35] refactor: split the go generator into the ADR-0020 stage files --- .../src/generators/go/client.ts | 48 ++ .../src/generators/go/descriptor.ts | 32 + .../src/generators/go/index.ts | 804 +----------------- .../src/generators/go/models.ts | 173 ++++ .../src/generators/go/naming.ts | 41 + .../src/generators/go/operations.ts | 293 +++++++ .../src/generators/go/pagination.ts | 190 +++++ .../src/generators/go/types.ts | 52 ++ 8 files changed, 843 insertions(+), 790 deletions(-) create mode 100644 packages/client-generator/src/generators/go/client.ts create mode 100644 packages/client-generator/src/generators/go/descriptor.ts create mode 100644 packages/client-generator/src/generators/go/models.ts create mode 100644 packages/client-generator/src/generators/go/naming.ts create mode 100644 packages/client-generator/src/generators/go/operations.ts create mode 100644 packages/client-generator/src/generators/go/pagination.ts create mode 100644 packages/client-generator/src/generators/go/types.ts diff --git a/packages/client-generator/src/generators/go/client.ts b/packages/client-generator/src/generators/go/client.ts new file mode 100644 index 0000000000..ee6aca7618 --- /dev/null +++ b/packages/client-generator/src/generators/go/client.ts @@ -0,0 +1,48 @@ +// The `client` stage: one `URL` function per declared server. + +import { identifierFor, serverUrlParts } from '../../authoring/index.js'; +import type { ApiModel, ServerModel } from '../../intermediate-representation/model.js'; +import { exported, type GoPrinter } from '../../printers/go.js'; +import { GO, naming } from './naming.js'; + +/** The server URL as a Go expression: literals concatenated with declared-variable args. */ +function serverUrlExpression(server: ServerModel): string { + const parts = serverUrlParts(server).map((part) => + part.kind === 'literal' + ? naming.string(part.value) + : identifierFor(part.name, { style: 'camel', reserved: GO }) + ); + return parts.join(' + '); +} + +/** One `URL` function per declared server; server variables become parameters. */ +export function writeGoServers(printer: GoPrinter, model: ApiModel): void { + const servers = model.servers ?? []; + if (servers.length === 0) return; + const usedNames = new Set(); + servers.forEach((server, index) => { + let name = `${exported(server.description ?? `server${index + 1}`)}URL`; + if (usedNames.has(name)) name = `${name}${index + 1}`; + usedNames.add(name); + const params = server.variables.map( + (variable) => `${identifierFor(variable.name, { style: 'camel', reserved: GO })} string` + ); + const defaults = server.variables + .map( + (variable) => + `${identifierFor(variable.name, { style: 'camel', reserved: GO })} default: ${naming.string(variable.default)}` + ) + .join(', '); + printer.line( + `// ${name} returns the ${naming.string(server.description ?? server.url)} base URL${defaults === '' ? '.' : ` (${defaults}).`}` + ); + printer.block( + `func ${name}(${params.join(', ')}) string {`, + () => { + printer.line(`return ${serverUrlExpression(server)}`); + }, + '}' + ); + printer.blank(); + }); +} diff --git a/packages/client-generator/src/generators/go/descriptor.ts b/packages/client-generator/src/generators/go/descriptor.ts new file mode 100644 index 0000000000..669195c072 --- /dev/null +++ b/packages/client-generator/src/generators/go/descriptor.ts @@ -0,0 +1,32 @@ +// The `descriptor` stage: the operations-table composite literals — security +// OR-alternatives and the pagination spec. + +import { securityRequirements, type NeutralPaginationRule } from '../../authoring/index.js'; +import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; +import { naming } from './naming.js'; + +/** Go composite literal for one operation's security OR-alternatives. */ +export function goSecurityLiteral(op: OperationModel, model: ApiModel): string | undefined { + const alternatives = securityRequirements(op, model).map((alternative) => + alternative.map((spec) => + spec.kind === 'apiKey' + ? `{Scheme: ${naming.string(spec.scheme)}, Kind: "apiKey", Name: ${naming.string(spec.name)}, In: ${naming.string(spec.in)}}` + : `{Scheme: ${naming.string(spec.scheme)}, Kind: ${naming.string(spec.kind)}}` + ) + ); + if (alternatives.length === 0) return undefined; + return `[][]SecuritySpec{${alternatives.map((specs) => `{${specs.join(', ')}}`).join(', ')}}`; +} + +/** The neutral rule as a `&PaginationSpec{…}` composite literal for the operations table. */ +export function goPaginationLiteral(rule: NeutralPaginationRule): string { + const fields = [ + `Style: ${naming.string(rule.style)}`, + ...(rule.param !== undefined ? [`Param: ${naming.string(rule.param)}`] : []), + ...(rule.nextCursor !== undefined ? [`NextCursor: ${naming.string(rule.nextCursor)}`] : []), + ...(rule.hasMore !== undefined ? [`HasMore: ${naming.string(rule.hasMore)}`] : []), + ...(rule.limitParam !== undefined ? [`LimitParam: ${naming.string(rule.limitParam)}`] : []), + ...(rule.items !== undefined ? [`Items: ${naming.string(rule.items)}`] : []), + ]; + return `&PaginationSpec{${fields.join(', ')}}`; +} diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index be91f0224d..5154d1be63 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -3,309 +3,30 @@ // the python generator, pinned by its guard test). Output is a single // stdlib-only Go file: structs with json tags, typed-const enums, discriminated // unions with unmarshal dispatchers, and a Client over the embedded runtime. +// One file per pipeline stage (ADR-0020); this entry assembles them. import { - casing, - discriminatorCases, - enumValues, - flattenAllOf, - headerCoerceType, identifierFor, - uniqueIdentifiers, - isNullable, - NotSupportedError, - renderReferencePage, - RESERVED_WORDS, - unwrapNullable, - type DateType, - type NeutralPaginationRule, - isMultipartBody, jsonSuccessSchema, - sseResponse, - serverUrlParts, - securityRequirements, paginationItemSchema, + renderReferencePage, + sseResponse, + 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 { OperationModel } from '../../intermediate-representation/model.js'; import { exported, GoPrinter } from '../../printers/go.js'; - -// One escaping policy for every Go string literal this generator prints. -const naming = new GoPrinter(); import type { CodeSample, Generator, SampleContext } from '../types.js'; +import { writeGoServers } from './client.js'; +import { goPaginationLiteral, goSecurityLiteral } from './descriptor.js'; +import { renderGoModels } from './models.js'; +import { GO, goOperationIdents, goPackageName, naming } from './naming.js'; +import { writeGoMethod } from './operations.js'; +import { writeGoPaginationWrappers } from './pagination.js'; +import { goType } 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). */ -/** 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 writeStruct( - printer: GoPrinter, - name: string, - properties: PropertyModel[], - dateType: DateType, - description?: string -): void { - printer.doc(exported(name), description); - printer.block( - `type ${exported(name)} struct {`, - () => { - for (const property of properties) { - const field = exported(property.name); - let fieldType = goType(property.schema, dateType); - let tag = `\`json:"${property.name}"\``; - if (!property.required) { - if ( - !fieldType.startsWith('*') && - !fieldType.startsWith('[]') && - !fieldType.startsWith('map[') && - fieldType !== 'any' - ) { - fieldType = `*${fieldType}`; - } - tag = `\`json:"${property.name},omitempty"\``; - } - printer.line(`${field} ${fieldType} ${tag}`); - } - }, - '}' - ); - printer.blank(); -} - -/** Render every named schema: typed-const enums, structs (allOf flattened), union dispatchers. */ -export function renderGoModels(model: ApiModel, dateType: DateType = 'string'): string { - const printer = new GoPrinter(); - printer.line('package client'); - printer.blank(); - const needsJSON = model.schemas.some( - ({ schema }) => discriminatorCases(schema, model) !== undefined - ); - if (needsJSON) { - printer.line('import "encoding/json"'); - printer.blank(); - } - // The models section also compiles standalone (see the unit bars), so it declares - // its own `time` import when a field is a date. - const body = renderGoModelBodies(model, dateType); - if (dateType === 'Date' && body.includes('time.Time')) { - printer.line('import "time"'); - printer.blank(); - } - printer.line(body); - return printer.toString(); -} - -/** The struct/enum/union declarations themselves — the header is renderGoModels' job. */ -function renderGoModelBodies(model: ApiModel, dateType: DateType): string { - const printer = new GoPrinter(); - - for (const { name, schema } of model.schemas) { - const asEnum = enumValues(schema); - if (asEnum !== undefined) { - const base = asEnum.scalar === 'string' ? 'string' : 'int64'; - printer.doc(exported(name), schema.description); - printer.line(`type ${exported(name)} ${base}`); - printer.blank(); - printer.block( - 'const (', - () => { - // Two values may fold to one pascal name (`1.5` and `15`) — a duplicate const - // would not compile, so the names are made unique per enum. A digit-leading - // value needs no `_` prefix here: the member starts with the type name. - const used = new Set(); - asEnum.values.forEach((value) => { - const base = casing.pascal(String(value)) || 'Value'; - let suffix = ''; - for (let n = 2; used.has(base + suffix); n++) suffix = String(n); - used.add(base + suffix); - const member = exported(name) + base + suffix; - printer.line(`${member} ${exported(name)} = ${naming.literal(value)}`); - }); - }, - ')' - ); - printer.blank(); - continue; - } - if (schema.kind === 'object' || schema.kind === 'intersection') { - const flat = flattenAllOf(schema, model); - if (flat !== undefined) { - writeStruct( - printer, - name, - flat.properties, - dateType, - flat.description ?? schema.description - ); - continue; - } - } - const cases = discriminatorCases(schema, model); - if (cases !== undefined) { - const typeName = exported(name); - const table = cases.cases - .map((entry) => `${entry.value} -> ${exported(entry.schemaName)}`) - .join(', '); - printer.line(`// ${typeName} is a discriminated union ("${cases.property}"): ${table}.`); - printer.line(`type ${typeName} = any`); - printer.blank(); - printer.line( - `// Unmarshal${typeName} decodes into the member selected by "${cases.property}".` - ); - printer.block( - `func Unmarshal${typeName}(data []byte) (${typeName}, error) {`, - () => { - printer.block( - 'var probe struct {', - () => { - printer.line(`Discriminant string \`json:"${cases.property}"\``); - }, - '}' - ); - printer.block( - 'if err := json.Unmarshal(data, &probe); err != nil {', - () => { - printer.line('return nil, err'); - }, - '}' - ); - // gofmt keeps `case` at the switch's own indent, so the switch body is NOT - // indented as a block — only each case's statements are. - printer.line('switch probe.Discriminant {'); - for (const entry of cases.cases) { - printer.block(`case ${naming.string(entry.value)}:`, () => { - printer.line(`var value ${exported(entry.schemaName)}`); - printer.line('err := json.Unmarshal(data, &value)'); - printer.line('return value, err'); - }); - } - printer.line('}'); - printer.line('var fallback any'); - printer.line('err := json.Unmarshal(data, &fallback)'); - printer.line('return fallback, err'); - }, - '}' - ); - printer.blank(); - continue; - } - // Everything else (plain unions, scalar aliases, records) becomes a type alias. - printer.doc(exported(name), schema.description); - printer.line(`type ${exported(name)} = ${goType(schema, dateType)}`); - printer.blank(); - } - return printer.toString(); -} - -/** Go composite literal for one operation's security OR-alternatives. */ -function goSecurityLiteral(op: OperationModel, model: ApiModel): string | undefined { - const alternatives = securityRequirements(op, model).map((alternative) => - alternative.map((spec) => - spec.kind === 'apiKey' - ? `{Scheme: ${naming.string(spec.scheme)}, Kind: "apiKey", Name: ${naming.string(spec.name)}, In: ${naming.string(spec.in)}}` - : `{Scheme: ${naming.string(spec.scheme)}, Kind: ${naming.string(spec.kind)}}` - ) - ); - if (alternatives.length === 0) return undefined; - return `[][]SecuritySpec{${alternatives.map((specs) => `{${specs.join(', ')}}`).join(', ')}}`; -} - -/** 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})`; -} +export { renderGoModels } from './models.js'; +export { goType } from './types.js'; /** Strip the package clause and import lines/blocks so a section stitches into one file. */ function stripHeader(source: string): string { @@ -328,503 +49,6 @@ function stripHeader(source: string): string { return out.join('\n').trim(); } -/** The neutral rule as a `&PaginationSpec{…}` composite literal for the operations table. */ -function goPaginationLiteral(rule: NeutralPaginationRule): string { - const fields = [ - `Style: ${naming.string(rule.style)}`, - ...(rule.param !== undefined ? [`Param: ${naming.string(rule.param)}`] : []), - ...(rule.nextCursor !== undefined ? [`NextCursor: ${naming.string(rule.nextCursor)}`] : []), - ...(rule.hasMore !== undefined ? [`HasMore: ${naming.string(rule.hasMore)}`] : []), - ...(rule.limitParam !== undefined ? [`LimitParam: ${naming.string(rule.limitParam)}`] : []), - ...(rule.items !== undefined ? [`Items: ${naming.string(rule.items)}`] : []), - ]; - return `&PaginationSpec{${fields.join(', ')}}`; -} - -/** - * 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: GoPrinter, - op: OperationModel, - ident: string, - dateType: DateType, - model?: ApiModel, - envelope = false -): void { - const pathArgs = pathArguments(op, dateType); - const hasParams = op.queryParams.length > 0; - const success = jsonSuccessSchema(op); - const returnType = success === undefined ? undefined : goType(success, dateType); - const headerPlan = envelope ? envelopeHeaderPlan(op, model!) : []; - if (envelope) { - printer.line( - `// ${ident}Headers carries the declared response headers of ${ident}WithHeaders (nil when absent or unparsable).` - ); - printer.block( - `type ${ident}Headers struct {`, - () => { - for (const planned of headerPlan) printer.line(`${planned.field} ${planned.goType}`); - }, - '}' - ); - printer.blank(); - } - const args = [ - 'ctx context.Context', - ...pathArgs.map(({ go, type }) => `${go} ${type}`), - ...(op.requestBody ? [`body ${goType(op.requestBody.schema, dateType)}`] : []), - ...(hasParams ? [`params *${ident}Params`] : []), - ]; - const sse = sseResponse(op); - const returns = envelope - ? returnType === undefined - ? `(${ident}Headers, error)` - : `(${returnType}, ${ident}Headers, error)` - : sse !== undefined - ? 'func(yield func(ServerSentEvent, error) bool)' - : returnType === undefined - ? 'error' - : `(${returnType}, error)`; - const fail = (errExpr: string) => - envelope - ? returnType === undefined - ? `return headers, ${errExpr}` - : `return out, headers, ${errExpr}` - : returnType === undefined - ? `return ${errExpr}` - : `return out, ${errExpr}`; - const funcName = envelope ? `${ident}WithHeaders` : ident; - printer.doc( - funcName, - envelope ? `Like ${ident}, also returning the declared response headers.` : op.summary - ); - printer.block( - `func (c *Client) ${funcName}(${args.join(', ')}) ${returns} {`, - () => { - if (sse === undefined && returnType !== undefined) printer.line(`var out ${returnType}`); - if (envelope) printer.line(`var headers ${ident}Headers`); - printer.line(`op := operations[${naming.string(op.specName ?? op.name)}]`); - printer.line('authHeaders, query := resolveAuth(op.Security, c.config.Auth)'); - if (hasParams) { - printer.block( - 'if params != nil {', - () => { - for (const param of op.queryParams) { - const field = exported(param.name); - printer.block( - `if params.${field} != nil {`, - () => { - // An array repeats the key per element (OpenAPI `form` + `explode`, the - // default — and what the TS runtime sends). `fmt.Sprint` of a slice - // would put `[a b]` on the wire as one value. - if (param.schema.kind === 'array') { - const elementType = goType(param.schema.items, dateType); - printer.block( - `for _, item := range *params.${field} {`, - () => { - printer.line( - `query.Add(${naming.string(param.name)}, ${goQueryFormat('item', elementType)})` - ); - }, - '}' - ); - } else { - printer.line( - `query.Set(${naming.string(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema, dateType))})` - ); - } - }, - '}' - ); - } - }, - '}' - ); - } - const pathDict = pathArgs - .map(({ param, go, type }) => `${naming.string(param.name)}: ${goQueryFormat(go, type)}`) - .join(', '); - printer.line( - `requestURL := buildURL(c.config.ServerURL, op.Path, map[string]string{${pathDict}})` - ); - if (sse !== undefined) { - printer.block( - 'open := func(extraHeaders map[string]string) (*http.Response, error) {', - () => { - printer.line('merged := map[string]string{}'); - printer.block( - 'for key, value := range authHeaders {', - () => { - printer.line('merged[key] = value'); - }, - '}' - ); - printer.block( - 'for key, value := range extraHeaders {', - () => { - printer.line('merged[key] = value'); - }, - '}' - ); - printer.line( - 'return send(ctx, &c.config, requestSpec{OperationID: op.ID, Method: op.Method, URL: requestURL, Headers: merged, Query: query})' - ); - }, - '}' - ); - printer.line( - `return iterSSE(open, ${sse.schema !== undefined && sse.schema.kind !== 'unknown'})` - ); - return; - } - const specFields = [ - 'OperationID: op.ID', - 'Method: op.Method', - 'URL: requestURL', - 'Headers: authHeaders', - 'Query: query', - ]; - if (op.requestBody && isMultipartBody(op)) { - printer.line('contentType, reader, err := toMultipart(body)'); - printer.block( - 'if err != nil {', - () => { - printer.line(fail('err')); - }, - '}' - ); - specFields.push('Body: reader'); - specFields.push('ContentType: contentType'); - } else if (op.requestBody) { - printer.line('payload, err := json.Marshal(body)'); - printer.block( - 'if err != nil {', - () => { - printer.line(fail('err')); - }, - '}' - ); - specFields.push('Body: bytes.NewReader(payload)'); - specFields.push(`ContentType: ${naming.string(op.requestBody.contentType)}`); - } - printer.line(`resp, err := send(ctx, &c.config, requestSpec{${specFields.join(', ')}})`); - printer.block( - 'if err != nil {', - () => { - printer.line(fail('err')); - }, - '}' - ); - printer.block( - 'if resp.StatusCode >= 400 {', - () => { - printer.line(fail('apiErrorFrom(resp, requestURL)')); - }, - '}' - ); - if (envelope) { - printer.block( - `if err := decodeJSON(resp, ${returnType === undefined ? 'nil' : '&out'}); err != nil {`, - () => { - printer.line(fail('err')); - }, - '}' - ); - for (const planned of headerPlan) { - printer.line( - `headers.${planned.field} = ${planned.helper}(resp.Header, ${naming.string(planned.name)})` - ); - } - printer.line(returnType === undefined ? 'return headers, nil' : 'return out, headers, nil'); - } else if (returnType === undefined) { - printer.line('return decodeJSON(resp, nil)'); - } else { - printer.block( - 'if err := decodeJSON(resp, &out); err != nil {', - () => { - printer.line('return out, err'); - }, - '}' - ); - printer.line('return out, nil'); - } - }, - '}' - ); - printer.blank(); -} - -/** `Pages` / `Items` iterators over the runtime's `iterPages`, hydrated via `reencode`. */ -function writeGoPaginationWrappers( - printer: GoPrinter, - op: OperationModel, - ident: string, - dateType: DateType, - pageType: string, - itemType: string -): void { - const pathArgs = pathArguments(op, dateType); - const hasParams = op.queryParams.length > 0; - const args = [ - 'ctx context.Context', - ...pathArgs.map(({ go, type }) => `${go} ${type}`), - ...(hasParams ? [`params *${ident}Params`] : []), - ].join(', '); - - const writeCallClosure = () => { - printer.line(`op := operations[${naming.string(op.specName ?? op.name)}]`); - printer.line('base := url.Values{}'); - if (hasParams) { - printer.block( - 'if params != nil {', - () => { - for (const param of op.queryParams) { - const field = exported(param.name); - printer.block( - `if params.${field} != nil {`, - () => { - printer.line( - `base.Set(${naming.string(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema, dateType))})` - ); - }, - '}' - ); - } - }, - '}' - ); - } - printer.block( - 'call := func(pageParams url.Values) (any, *http.Response, error) {', - () => { - printer.line('authHeaders, query := resolveAuth(op.Security, c.config.Auth)'); - printer.block( - 'for key, values := range pageParams {', - () => { - printer.block( - 'for _, value := range values {', - () => { - printer.line('query.Set(key, value)'); - }, - '}' - ); - }, - '}' - ); - const pathDict = pathArgs - .map(({ param, go, type }) => `${naming.string(param.name)}: ${goQueryFormat(go, type)}`) - .join(', '); - printer.line( - `requestURL := buildURL(c.config.ServerURL, op.Path, map[string]string{${pathDict}})` - ); - printer.line( - 'resp, err := send(ctx, &c.config, requestSpec{OperationID: op.ID, Method: op.Method, URL: requestURL, Headers: authHeaders, Query: query})' - ); - printer.block( - 'if err != nil {', - () => { - printer.line('return nil, nil, err'); - }, - '}' - ); - printer.block( - 'if resp.StatusCode >= 400 {', - () => { - printer.line('return nil, resp, apiErrorFrom(resp, requestURL)'); - }, - '}' - ); - printer.line('var raw any'); - printer.block( - 'if err := decodeJSON(resp, &raw); err != nil {', - () => { - printer.line('return nil, resp, err'); - }, - '}' - ); - printer.line('return raw, resp, nil'); - }, - '}' - ); - printer.line('pages := iterPages(call, *op.Pagination, base)'); - }; - - printer.line( - `// ${ident}Pages iterates ${ident} response pages; use with \`for page, err := range\`.` - ); - printer.block( - `func (c *Client) ${ident}Pages(${args}) func(yield func(${pageType}, error) bool) {`, - () => { - writeCallClosure(); - printer.block( - `return func(yield func(${pageType}, error) bool) {`, - () => { - printer.block( - 'pages(func(raw any, err error) bool {', - () => { - printer.line(`var page ${pageType}`); - printer.block( - 'if err == nil {', - () => { - printer.line('err = reencode(raw, &page)'); - }, - '}' - ); - printer.line('return yield(page, err)'); - }, - '})' - ); - }, - '}' - ); - }, - '}' - ); - printer.blank(); - - printer.line(`// ${ident}Items iterates the items of every ${ident} page.`); - printer.block( - `func (c *Client) ${ident}Items(${args}) func(yield func(${itemType}, error) bool) {`, - () => { - writeCallClosure(); - printer.block( - `return func(yield func(${itemType}, error) bool) {`, - () => { - printer.block( - 'pages(func(raw any, err error) bool {', - () => { - printer.block( - 'if err != nil {', - () => { - printer.line(`var zero ${itemType}`); - printer.line('return yield(zero, err)'); - }, - '}' - ); - printer.line('pageItems, _ := resolvePointer(raw, op.Pagination.Items).([]any)'); - printer.block( - 'for _, item := range pageItems {', - () => { - printer.line(`var typed ${itemType}`); - printer.block( - 'if err := reencode(item, &typed); err != nil {', - () => { - printer.line('return yield(typed, err)'); - }, - '}' - ); - printer.block( - 'if !yield(typed, nil) {', - () => { - printer.line('return false'); - }, - '}' - ); - }, - '}' - ); - printer.line('return true'); - }, - '})' - ); - }, - '}' - ); - }, - '}' - ); - printer.blank(); -} - -/** The server URL as a Go expression: literals concatenated with declared-variable args. */ -function serverUrlExpression(server: ServerModel): string { - const parts = serverUrlParts(server).map((part) => - part.kind === 'literal' - ? naming.string(part.value) - : identifierFor(part.name, { style: 'camel', reserved: GO }) - ); - return parts.join(' + '); -} - -/** One `URL` function per declared server; server variables become parameters. */ -function writeGoServers(printer: GoPrinter, model: ApiModel): void { - const servers = model.servers ?? []; - if (servers.length === 0) return; - const usedNames = new Set(); - servers.forEach((server, index) => { - let name = `${exported(server.description ?? `server${index + 1}`)}URL`; - if (usedNames.has(name)) name = `${name}${index + 1}`; - usedNames.add(name); - const params = server.variables.map( - (variable) => `${identifierFor(variable.name, { style: 'camel', reserved: GO })} string` - ); - const defaults = server.variables - .map( - (variable) => - `${identifierFor(variable.name, { style: 'camel', reserved: GO })} default: ${naming.string(variable.default)}` - ) - .join(', '); - printer.line( - `// ${name} returns the ${naming.string(server.description ?? server.url)} base URL${defaults === '' ? '.' : ` (${defaults}).`}` - ); - printer.block( - `func ${name}(${params.join(', ')}) string {`, - () => { - printer.line(`return ${serverUrlExpression(server)}`); - }, - '}' - ); - printer.blank(); - }); -} - /** The whole generated file: models + embedded runtime + operations table + Client. */ export const goGenerator: Generator = ({ model, output, emit, pagination }) => { const printer = new GoPrinter(); diff --git a/packages/client-generator/src/generators/go/models.ts b/packages/client-generator/src/generators/go/models.ts new file mode 100644 index 0000000000..65f45c79c8 --- /dev/null +++ b/packages/client-generator/src/generators/go/models.ts @@ -0,0 +1,173 @@ +// The `models` stage: named schemas as typed-const enums, structs with json tags +// (allOf flattened), and discriminated unions with unmarshal dispatchers. + +import { + casing, + discriminatorCases, + enumValues, + flattenAllOf, + type DateType, +} from '../../authoring/index.js'; +import type { ApiModel, PropertyModel } from '../../intermediate-representation/model.js'; +import { exported, GoPrinter } from '../../printers/go.js'; +import { naming } from './naming.js'; +import { goType } from './types.js'; + +function writeStruct( + printer: GoPrinter, + name: string, + properties: PropertyModel[], + dateType: DateType, + description?: string +): void { + printer.doc(exported(name), description); + printer.block( + `type ${exported(name)} struct {`, + () => { + for (const property of properties) { + const field = exported(property.name); + let fieldType = goType(property.schema, dateType); + let tag = `\`json:"${property.name}"\``; + if (!property.required) { + if ( + !fieldType.startsWith('*') && + !fieldType.startsWith('[]') && + !fieldType.startsWith('map[') && + fieldType !== 'any' + ) { + fieldType = `*${fieldType}`; + } + tag = `\`json:"${property.name},omitempty"\``; + } + printer.line(`${field} ${fieldType} ${tag}`); + } + }, + '}' + ); + printer.blank(); +} + +/** Render every named schema: typed-const enums, structs (allOf flattened), union dispatchers. */ +export function renderGoModels(model: ApiModel, dateType: DateType = 'string'): string { + const printer = new GoPrinter(); + printer.line('package client'); + printer.blank(); + const needsJSON = model.schemas.some( + ({ schema }) => discriminatorCases(schema, model) !== undefined + ); + if (needsJSON) { + printer.line('import "encoding/json"'); + printer.blank(); + } + // The models section also compiles standalone (see the unit bars), so it declares + // its own `time` import when a field is a date. + const body = renderGoModelBodies(model, dateType); + if (dateType === 'Date' && body.includes('time.Time')) { + printer.line('import "time"'); + printer.blank(); + } + printer.line(body); + return printer.toString(); +} + +/** The struct/enum/union declarations themselves — the header is renderGoModels' job. */ +function renderGoModelBodies(model: ApiModel, dateType: DateType): string { + const printer = new GoPrinter(); + + for (const { name, schema } of model.schemas) { + const asEnum = enumValues(schema); + if (asEnum !== undefined) { + const base = asEnum.scalar === 'string' ? 'string' : 'int64'; + printer.doc(exported(name), schema.description); + printer.line(`type ${exported(name)} ${base}`); + printer.blank(); + printer.block( + 'const (', + () => { + // Two values may fold to one pascal name (`1.5` and `15`) — a duplicate const + // would not compile, so the names are made unique per enum. A digit-leading + // value needs no `_` prefix here: the member starts with the type name. + const used = new Set(); + asEnum.values.forEach((value) => { + const base = casing.pascal(String(value)) || 'Value'; + let suffix = ''; + for (let n = 2; used.has(base + suffix); n++) suffix = String(n); + used.add(base + suffix); + const member = exported(name) + base + suffix; + printer.line(`${member} ${exported(name)} = ${naming.literal(value)}`); + }); + }, + ')' + ); + printer.blank(); + continue; + } + if (schema.kind === 'object' || schema.kind === 'intersection') { + const flat = flattenAllOf(schema, model); + if (flat !== undefined) { + writeStruct( + printer, + name, + flat.properties, + dateType, + flat.description ?? schema.description + ); + continue; + } + } + const cases = discriminatorCases(schema, model); + if (cases !== undefined) { + const typeName = exported(name); + const table = cases.cases + .map((entry) => `${entry.value} -> ${exported(entry.schemaName)}`) + .join(', '); + printer.line(`// ${typeName} is a discriminated union ("${cases.property}"): ${table}.`); + printer.line(`type ${typeName} = any`); + printer.blank(); + printer.line( + `// Unmarshal${typeName} decodes into the member selected by "${cases.property}".` + ); + printer.block( + `func Unmarshal${typeName}(data []byte) (${typeName}, error) {`, + () => { + printer.block( + 'var probe struct {', + () => { + printer.line(`Discriminant string \`json:"${cases.property}"\``); + }, + '}' + ); + printer.block( + 'if err := json.Unmarshal(data, &probe); err != nil {', + () => { + printer.line('return nil, err'); + }, + '}' + ); + // gofmt keeps `case` at the switch's own indent, so the switch body is NOT + // indented as a block — only each case's statements are. + printer.line('switch probe.Discriminant {'); + for (const entry of cases.cases) { + printer.block(`case ${naming.string(entry.value)}:`, () => { + printer.line(`var value ${exported(entry.schemaName)}`); + printer.line('err := json.Unmarshal(data, &value)'); + printer.line('return value, err'); + }); + } + printer.line('}'); + printer.line('var fallback any'); + printer.line('err := json.Unmarshal(data, &fallback)'); + printer.line('return fallback, err'); + }, + '}' + ); + printer.blank(); + continue; + } + // Everything else (plain unions, scalar aliases, records) becomes a type alias. + printer.doc(exported(name), schema.description); + printer.line(`type ${exported(name)} = ${goType(schema, dateType)}`); + printer.blank(); + } + return printer.toString(); +} diff --git a/packages/client-generator/src/generators/go/naming.ts b/packages/client-generator/src/generators/go/naming.ts new file mode 100644 index 0000000000..b8d0ee79cc --- /dev/null +++ b/packages/client-generator/src/generators/go/naming.ts @@ -0,0 +1,41 @@ +// The `naming` stage: the shared printer/naming instance, the package clause, and +// the collision-free operation identifiers every other stage builds on. + +import { NotSupportedError, RESERVED_WORDS } from '../../authoring/index.js'; +import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; +import { exported, GoPrinter } from '../../printers/go.js'; + +// One escaping policy for every Go string literal this generator prints. +export const naming = new GoPrinter(); + +export const GO = RESERVED_WORDS.go; + +/** + * The package clause the output declares. Rewriting an invalid name would hide the + * publisher's typo behind a package their imports don't mention, so this rejects it. + */ +export function goPackageName(configured: string | undefined): string { + if (configured === undefined) return 'client'; + if (!/^[a-z_][a-z0-9_]*$/.test(configured) || GO.has(configured)) { + throw new NotSupportedError( + `goPackage "${configured}" is not a valid Go package name: use lowercase letters, digits, and underscores, don't start with a digit, and avoid Go keywords.` + ); + } + return configured; +} + +/** Every operation with its collision-free exported Go method name. */ +export function goOperationIdents(model: ApiModel): Array<{ op: OperationModel; ident: string }> { + const used = new Set(); + const out: Array<{ op: OperationModel; ident: string }> = []; + for (const service of model.services) { + for (const op of service.operations) { + let ident = exported(op.name); + let suffix = 2; + while (used.has(ident)) ident = `${exported(op.name)}${suffix++}`; + used.add(ident); + out.push({ op, ident }); + } + } + return out; +} diff --git a/packages/client-generator/src/generators/go/operations.ts b/packages/client-generator/src/generators/go/operations.ts new file mode 100644 index 0000000000..a6af87ae6a --- /dev/null +++ b/packages/client-generator/src/generators/go/operations.ts @@ -0,0 +1,293 @@ +// The `operations` stage: one typed request method per operation, plus the +// argument and envelope-header planning it shares with the pagination wrappers. + +import { + headerCoerceType, + isMultipartBody, + jsonSuccessSchema, + sseResponse, + uniqueIdentifiers, + type DateType, +} from '../../authoring/index.js'; +import type { + ApiModel, + OperationModel, + ParamModel, +} from '../../intermediate-representation/model.js'; +import { exported, type GoPrinter } from '../../printers/go.js'; +import { GO, naming } from './naming.js'; +import { goType } from './types.js'; + +/** A query-value expression formatted to string for url.Values. */ +export function goQueryFormat(expr: string, type: string): string { + if (type === 'string') return expr; + // Dates serialize in their wire layout, not Go's default String(). A dereferenced + // pointer needs parentheses: `*p.Format(…)` would deref Format's result. + const receiver = expr.startsWith('*') ? `(${expr})` : expr; + if (type === 'time.Time') return `${receiver}.Format(time.RFC3339)`; + if (type === 'Date') return `${receiver}.Format("2006-01-02")`; + if (type === 'int64') return `strconv.FormatInt(${expr}, 10)`; + if (type === 'float64') return `strconv.FormatFloat(${expr}, 'f', -1, 64)`; + if (type === 'bool') return `strconv.FormatBool(${expr})`; + return `fmt.Sprint(${expr})`; +} + +/** + * The argument names a method declares beside its path parameters: the receiver, the + * context, the request body, and the query struct. + */ +const METHOD_ARG_SLOTS = ['c', 'ctx', 'body', 'params', 'out', 'op']; + +/** + * Path parameters as Go arguments, uniquely named. A parameter named after one of the + * method's own arguments (or a name a description reuses across locations) moves aside as + * `id2` — Go rejects a duplicate parameter, and the wire name is untouched either way. + */ +export function pathArguments( + op: OperationModel, + dateType: DateType +): Array<{ param: ParamModel; go: string; type: string }> { + const names = uniqueIdentifiers( + op.pathParams.map((param) => param.name), + { style: 'camel', reserved: GO, taken: METHOD_ARG_SLOTS } + ); + return op.pathParams.map((param, index) => ({ + param, + go: names[index], + type: goType(param.schema, dateType), + })); +} + +/** Declared response headers planned for the `Headers` struct: field, wire name, coerce helper. */ +function envelopeHeaderPlan( + op: OperationModel, + model: ApiModel +): Array<{ field: string; name: string; goType: string; helper: string }> { + const used = new Set(); + return (op.successResponseHeaders ?? []).map((header) => { + const base = exported(header.name); + let field = base; + let suffix = 2; + while (used.has(field)) field = `${base}${suffix++}`; + used.add(field); + const coerce = headerCoerceType(header.schema, model); + const mapping = { + integer: { goType: '*int64', helper: 'headerInt64' }, + number: { goType: '*float64', helper: 'headerFloat64' }, + boolean: { goType: '*bool', helper: 'headerBool' }, + string: { goType: '*string', helper: 'headerString' }, + }[coerce]; + return { field, name: header.name, ...mapping }; + }); +} + +export function writeGoMethod( + printer: GoPrinter, + op: OperationModel, + ident: string, + dateType: DateType, + model?: ApiModel, + envelope = false +): void { + const pathArgs = pathArguments(op, dateType); + const hasParams = op.queryParams.length > 0; + const success = jsonSuccessSchema(op); + const returnType = success === undefined ? undefined : goType(success, dateType); + const headerPlan = envelope ? envelopeHeaderPlan(op, model!) : []; + if (envelope) { + printer.line( + `// ${ident}Headers carries the declared response headers of ${ident}WithHeaders (nil when absent or unparsable).` + ); + printer.block( + `type ${ident}Headers struct {`, + () => { + for (const planned of headerPlan) printer.line(`${planned.field} ${planned.goType}`); + }, + '}' + ); + printer.blank(); + } + const args = [ + 'ctx context.Context', + ...pathArgs.map(({ go, type }) => `${go} ${type}`), + ...(op.requestBody ? [`body ${goType(op.requestBody.schema, dateType)}`] : []), + ...(hasParams ? [`params *${ident}Params`] : []), + ]; + const sse = sseResponse(op); + const returns = envelope + ? returnType === undefined + ? `(${ident}Headers, error)` + : `(${returnType}, ${ident}Headers, error)` + : sse !== undefined + ? 'func(yield func(ServerSentEvent, error) bool)' + : returnType === undefined + ? 'error' + : `(${returnType}, error)`; + const fail = (errExpr: string) => + envelope + ? returnType === undefined + ? `return headers, ${errExpr}` + : `return out, headers, ${errExpr}` + : returnType === undefined + ? `return ${errExpr}` + : `return out, ${errExpr}`; + const funcName = envelope ? `${ident}WithHeaders` : ident; + printer.doc( + funcName, + envelope ? `Like ${ident}, also returning the declared response headers.` : op.summary + ); + printer.block( + `func (c *Client) ${funcName}(${args.join(', ')}) ${returns} {`, + () => { + if (sse === undefined && returnType !== undefined) printer.line(`var out ${returnType}`); + if (envelope) printer.line(`var headers ${ident}Headers`); + printer.line(`op := operations[${naming.string(op.specName ?? op.name)}]`); + printer.line('authHeaders, query := resolveAuth(op.Security, c.config.Auth)'); + if (hasParams) { + printer.block( + 'if params != nil {', + () => { + for (const param of op.queryParams) { + const field = exported(param.name); + printer.block( + `if params.${field} != nil {`, + () => { + // An array repeats the key per element (OpenAPI `form` + `explode`, the + // default — and what the TS runtime sends). `fmt.Sprint` of a slice + // would put `[a b]` on the wire as one value. + if (param.schema.kind === 'array') { + const elementType = goType(param.schema.items, dateType); + printer.block( + `for _, item := range *params.${field} {`, + () => { + printer.line( + `query.Add(${naming.string(param.name)}, ${goQueryFormat('item', elementType)})` + ); + }, + '}' + ); + } else { + printer.line( + `query.Set(${naming.string(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema, dateType))})` + ); + } + }, + '}' + ); + } + }, + '}' + ); + } + const pathDict = pathArgs + .map(({ param, go, type }) => `${naming.string(param.name)}: ${goQueryFormat(go, type)}`) + .join(', '); + printer.line( + `requestURL := buildURL(c.config.ServerURL, op.Path, map[string]string{${pathDict}})` + ); + if (sse !== undefined) { + printer.block( + 'open := func(extraHeaders map[string]string) (*http.Response, error) {', + () => { + printer.line('merged := map[string]string{}'); + printer.block( + 'for key, value := range authHeaders {', + () => { + printer.line('merged[key] = value'); + }, + '}' + ); + printer.block( + 'for key, value := range extraHeaders {', + () => { + printer.line('merged[key] = value'); + }, + '}' + ); + printer.line( + 'return send(ctx, &c.config, requestSpec{OperationID: op.ID, Method: op.Method, URL: requestURL, Headers: merged, Query: query})' + ); + }, + '}' + ); + printer.line( + `return iterSSE(open, ${sse.schema !== undefined && sse.schema.kind !== 'unknown'})` + ); + return; + } + const specFields = [ + 'OperationID: op.ID', + 'Method: op.Method', + 'URL: requestURL', + 'Headers: authHeaders', + 'Query: query', + ]; + if (op.requestBody && isMultipartBody(op)) { + printer.line('contentType, reader, err := toMultipart(body)'); + printer.block( + 'if err != nil {', + () => { + printer.line(fail('err')); + }, + '}' + ); + specFields.push('Body: reader'); + specFields.push('ContentType: contentType'); + } else if (op.requestBody) { + printer.line('payload, err := json.Marshal(body)'); + printer.block( + 'if err != nil {', + () => { + printer.line(fail('err')); + }, + '}' + ); + specFields.push('Body: bytes.NewReader(payload)'); + specFields.push(`ContentType: ${naming.string(op.requestBody.contentType)}`); + } + printer.line(`resp, err := send(ctx, &c.config, requestSpec{${specFields.join(', ')}})`); + printer.block( + 'if err != nil {', + () => { + printer.line(fail('err')); + }, + '}' + ); + printer.block( + 'if resp.StatusCode >= 400 {', + () => { + printer.line(fail('apiErrorFrom(resp, requestURL)')); + }, + '}' + ); + if (envelope) { + printer.block( + `if err := decodeJSON(resp, ${returnType === undefined ? 'nil' : '&out'}); err != nil {`, + () => { + printer.line(fail('err')); + }, + '}' + ); + for (const planned of headerPlan) { + printer.line( + `headers.${planned.field} = ${planned.helper}(resp.Header, ${naming.string(planned.name)})` + ); + } + printer.line(returnType === undefined ? 'return headers, nil' : 'return out, headers, nil'); + } else if (returnType === undefined) { + printer.line('return decodeJSON(resp, nil)'); + } else { + printer.block( + 'if err := decodeJSON(resp, &out); err != nil {', + () => { + printer.line('return out, err'); + }, + '}' + ); + printer.line('return out, nil'); + } + }, + '}' + ); + printer.blank(); +} diff --git a/packages/client-generator/src/generators/go/pagination.ts b/packages/client-generator/src/generators/go/pagination.ts new file mode 100644 index 0000000000..1f6d1b97af --- /dev/null +++ b/packages/client-generator/src/generators/go/pagination.ts @@ -0,0 +1,190 @@ +// The `pagination` stage: the `Pages` / `Items` yield-func iterators. + +import type { DateType } from '../../authoring/index.js'; +import type { OperationModel } from '../../intermediate-representation/model.js'; +import { exported, type GoPrinter } from '../../printers/go.js'; +import { naming } from './naming.js'; +import { goQueryFormat, pathArguments } from './operations.js'; +import { goType } from './types.js'; + +/** `Pages` / `Items` iterators over the runtime's `iterPages`, hydrated via `reencode`. */ +export function writeGoPaginationWrappers( + printer: GoPrinter, + op: OperationModel, + ident: string, + dateType: DateType, + pageType: string, + itemType: string +): void { + const pathArgs = pathArguments(op, dateType); + const hasParams = op.queryParams.length > 0; + const args = [ + 'ctx context.Context', + ...pathArgs.map(({ go, type }) => `${go} ${type}`), + ...(hasParams ? [`params *${ident}Params`] : []), + ].join(', '); + + const writeCallClosure = () => { + printer.line(`op := operations[${naming.string(op.specName ?? op.name)}]`); + printer.line('base := url.Values{}'); + if (hasParams) { + printer.block( + 'if params != nil {', + () => { + for (const param of op.queryParams) { + const field = exported(param.name); + printer.block( + `if params.${field} != nil {`, + () => { + printer.line( + `base.Set(${naming.string(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema, dateType))})` + ); + }, + '}' + ); + } + }, + '}' + ); + } + printer.block( + 'call := func(pageParams url.Values) (any, *http.Response, error) {', + () => { + printer.line('authHeaders, query := resolveAuth(op.Security, c.config.Auth)'); + printer.block( + 'for key, values := range pageParams {', + () => { + printer.block( + 'for _, value := range values {', + () => { + printer.line('query.Set(key, value)'); + }, + '}' + ); + }, + '}' + ); + const pathDict = pathArgs + .map(({ param, go, type }) => `${naming.string(param.name)}: ${goQueryFormat(go, type)}`) + .join(', '); + printer.line( + `requestURL := buildURL(c.config.ServerURL, op.Path, map[string]string{${pathDict}})` + ); + printer.line( + 'resp, err := send(ctx, &c.config, requestSpec{OperationID: op.ID, Method: op.Method, URL: requestURL, Headers: authHeaders, Query: query})' + ); + printer.block( + 'if err != nil {', + () => { + printer.line('return nil, nil, err'); + }, + '}' + ); + printer.block( + 'if resp.StatusCode >= 400 {', + () => { + printer.line('return nil, resp, apiErrorFrom(resp, requestURL)'); + }, + '}' + ); + printer.line('var raw any'); + printer.block( + 'if err := decodeJSON(resp, &raw); err != nil {', + () => { + printer.line('return nil, resp, err'); + }, + '}' + ); + printer.line('return raw, resp, nil'); + }, + '}' + ); + printer.line('pages := iterPages(call, *op.Pagination, base)'); + }; + + printer.line( + `// ${ident}Pages iterates ${ident} response pages; use with \`for page, err := range\`.` + ); + printer.block( + `func (c *Client) ${ident}Pages(${args}) func(yield func(${pageType}, error) bool) {`, + () => { + writeCallClosure(); + printer.block( + `return func(yield func(${pageType}, error) bool) {`, + () => { + printer.block( + 'pages(func(raw any, err error) bool {', + () => { + printer.line(`var page ${pageType}`); + printer.block( + 'if err == nil {', + () => { + printer.line('err = reencode(raw, &page)'); + }, + '}' + ); + printer.line('return yield(page, err)'); + }, + '})' + ); + }, + '}' + ); + }, + '}' + ); + printer.blank(); + + printer.line(`// ${ident}Items iterates the items of every ${ident} page.`); + printer.block( + `func (c *Client) ${ident}Items(${args}) func(yield func(${itemType}, error) bool) {`, + () => { + writeCallClosure(); + printer.block( + `return func(yield func(${itemType}, error) bool) {`, + () => { + printer.block( + 'pages(func(raw any, err error) bool {', + () => { + printer.block( + 'if err != nil {', + () => { + printer.line(`var zero ${itemType}`); + printer.line('return yield(zero, err)'); + }, + '}' + ); + printer.line('pageItems, _ := resolvePointer(raw, op.Pagination.Items).([]any)'); + printer.block( + 'for _, item := range pageItems {', + () => { + printer.line(`var typed ${itemType}`); + printer.block( + 'if err := reencode(item, &typed); err != nil {', + () => { + printer.line('return yield(typed, err)'); + }, + '}' + ); + printer.block( + 'if !yield(typed, nil) {', + () => { + printer.line('return false'); + }, + '}' + ); + }, + '}' + ); + printer.line('return true'); + }, + '})' + ); + }, + '}' + ); + }, + '}' + ); + printer.blank(); +} diff --git a/packages/client-generator/src/generators/go/types.ts b/packages/client-generator/src/generators/go/types.ts new file mode 100644 index 0000000000..0e36cd43e8 --- /dev/null +++ b/packages/client-generator/src/generators/go/types.ts @@ -0,0 +1,52 @@ +// The `types` stage: the Go type annotation for a schema. + +import { isNullable, unwrapNullable, type DateType } from '../../authoring/index.js'; +import type { SchemaModel } from '../../intermediate-representation/model.js'; +import { exported } from '../../printers/go.js'; + +/** 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'; + } +} From 6687a5ac04b0eb8d476e009cdb2761119b717b56 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 22:21:48 +0300 Subject: [PATCH 25/35] refactor: split the php generator into the ADR-0020 stage files --- .../src/generators/php/client.ts | 54 ++ .../src/generators/php/descriptor.ts | 52 ++ .../src/generators/php/index.ts | 866 +----------------- .../src/generators/php/models.ts | 272 ++++++ .../src/generators/php/naming.ts | 42 + .../src/generators/php/operations.ts | 226 +++++ .../src/generators/php/pagination.ts | 124 +++ .../src/generators/php/types.ts | 142 +++ 8 files changed, 926 insertions(+), 852 deletions(-) create mode 100644 packages/client-generator/src/generators/php/client.ts create mode 100644 packages/client-generator/src/generators/php/descriptor.ts create mode 100644 packages/client-generator/src/generators/php/models.ts create mode 100644 packages/client-generator/src/generators/php/naming.ts create mode 100644 packages/client-generator/src/generators/php/operations.ts create mode 100644 packages/client-generator/src/generators/php/pagination.ts create mode 100644 packages/client-generator/src/generators/php/types.ts diff --git a/packages/client-generator/src/generators/php/client.ts b/packages/client-generator/src/generators/php/client.ts new file mode 100644 index 0000000000..6322770771 --- /dev/null +++ b/packages/client-generator/src/generators/php/client.ts @@ -0,0 +1,54 @@ +// The `client` stage: the `Servers` helper class of one static method per +// declared server. + +import { identifierFor, serverUrlParts } from '../../authoring/index.js'; +import type { ApiModel, ServerModel } from '../../intermediate-representation/model.js'; +import type { PhpPrinter } from '../../printers/php.js'; +import { PHP, phpString, propertyName } from './naming.js'; + +/** The server URL as a PHP expression: literals concatenated with declared-variable args. */ +function serverUrlExpression(server: ServerModel): string { + const parts = serverUrlParts(server).map((part) => + part.kind === 'literal' ? phpString(part.value) : `${'$'}${propertyName(part.name)}` + ); + return parts.join(' . '); +} + +/** One static method per declared server; server variables become named string arguments. */ +export function writeServers(printer: PhpPrinter, model: ApiModel): void { + const servers = model.servers ?? []; + if (servers.length === 0) return; + const usedNames = new Set(); + printer.line( + '/** The declared servers; variables default to the values from the description. */' + ); + printer.line('final class Servers'); + printer.block( + '{', + () => { + servers.forEach((server, index) => { + let name = identifierFor(server.description ?? `server${index + 1}`, { + style: 'camel', + reserved: PHP, + }); + if (usedNames.has(name)) name = `${name}${index + 1}`; + usedNames.add(name); + const params = server.variables.map( + (variable) => + `string ${'$'}${propertyName(variable.name)} = ${phpString(variable.default)}` + ); + if (index > 0) printer.blank(); + printer.line(`public static function ${name}(${params.join(', ')}): string`); + printer.block( + '{', + () => { + printer.line(`return ${serverUrlExpression(server)};`); + }, + '}' + ); + }); + }, + '}' + ); + printer.blank(); +} diff --git a/packages/client-generator/src/generators/php/descriptor.ts b/packages/client-generator/src/generators/php/descriptor.ts new file mode 100644 index 0000000000..8358dd9778 --- /dev/null +++ b/packages/client-generator/src/generators/php/descriptor.ts @@ -0,0 +1,52 @@ +// The `descriptor` stage: the operations-table array literals — security +// OR-alternatives, the pagination spec, and envelope-header coerce specs. + +import { + headerCoerceType, + identifierFor, + securityRequirements, + type NeutralPaginationRule, +} from '../../authoring/index.js'; +import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; +import { PHP, phpString } from './naming.js'; + +/** Security literal for the operations table, denormalized from the model's schemes. */ +export function phpSecurityLiteral(op: OperationModel, model: ApiModel): string | undefined { + const alternatives = securityRequirements(op, model).map((alternative) => { + const specs = alternative.map((spec) => + spec.kind === 'apiKey' + ? `['kind' => 'apiKey', 'scheme' => ${phpString(spec.scheme)}, 'name' => ${phpString(spec.name)}, 'in' => ${phpString(spec.in)}]` + : `['kind' => ${phpString(spec.kind)}, 'scheme' => ${phpString(spec.scheme)}]` + ); + return `[${specs.join(', ')}]`; + }); + if (alternatives.length === 0) return undefined; + return `[${alternatives.join(', ')}]`; +} + +export function phpPaginationLiteral(rule: NeutralPaginationRule): string { + const fields = [ + `'style' => ${phpString(rule.style)}`, + ...(rule.param !== undefined ? [`'param' => ${phpString(rule.param)}`] : []), + ...(rule.nextCursor !== undefined ? [`'nextCursor' => ${phpString(rule.nextCursor)}`] : []), + ...(rule.hasMore !== undefined ? [`'hasMore' => ${phpString(rule.hasMore)}`] : []), + ...(rule.limitParam !== undefined ? [`'limitParam' => ${phpString(rule.limitParam)}`] : []), + ...(rule.items !== undefined ? [`'items' => ${phpString(rule.items)}`] : []), + ]; + return `[${fields.join(', ')}]`; +} + +/** Declared response headers as runtime coerce specs: `[wire name, camelCase key, type]`. */ +export function envelopeHeaderSpecs(op: OperationModel, model: ApiModel): string { + const used = new Set(); + const specs = (op.successResponseHeaders ?? []).map((header) => { + let key = identifierFor(header.name, { style: 'camel', reserved: PHP }); + let suffix = 2; + while (used.has(key)) + key = `${identifierFor(header.name, { style: 'camel', reserved: PHP })}_${suffix++}`; + used.add(key); + const type = headerCoerceType(header.schema, model); + return `[${phpString(header.name)}, ${phpString(key)}, ${phpString(type)}]`; + }); + return `[${specs.join(', ')}]`; +} diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index cb4803cda6..5eae7f3d63 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -6,865 +6,27 @@ // embedded runtime. Exceptions are the error mode (`errorMode` does not apply). import { - discriminatorCases, - enumValues, - flattenAllOf, - headerCoerceType, identifierFor, - uniqueIdentifiers, - isNullable, - renderReferencePage, - RESERVED_WORDS, - unwrapNullable, - type NeutralPaginationRule, - type DateType, - isMultipartBody, jsonSuccessSchema, - sseResponse, - deref, - serverUrlParts, - securityRequirements, paginationItemSchema, + renderReferencePage, + sseResponse, + type NeutralPaginationRule, } 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 { OperationModel } from '../../intermediate-representation/model.js'; import { PhpPrinter } from '../../printers/php.js'; import type { CodeSample, Generator, SampleContext } from '../types.js'; - -const PHP = RESERVED_WORDS.php; - -// Naming and escaping delegate to the printer — one implementation, one policy. -const naming = new PhpPrinter(); - -function className(name: string): string { - return naming.typeName(name); -} - -function propertyName(name: string): string { - return naming.memberName(name); -} - -/** `'…'` with backslashes and quotes escaped — safe for any spec-supplied text. */ -function phpString(value: string): string { - return naming.string(value); -} - -/** 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, - // Required on purpose: a defaulted `'string'` let a call site forget it, and the method - // then returned a raw string where its own signature declared `\DateTimeImmutable`. - dateType: DateType -): string | undefined { - const bare = unwrapNullable(schema); - if (dateType === 'Date' && bare.kind === 'scalar' && bare.scalar === 'string') { - if (isDateFormat(bare)) return `new \\DateTimeImmutable(${expr})`; - } - if (bare.kind === 'omit') - return hydration({ kind: 'ref', name: bare.base }, expr, model, dateType); - if (bare.kind === 'ref') { - const kind = classify(bare.name, model); - if (kind === 'class') return `${className(bare.name)}::fromArray(${expr})`; - if (kind === 'enum') return `${className(bare.name)}::from(${expr})`; - if (isDiscriminatedUnion(bare.name, model)) return `unmarshal${className(bare.name)}(${expr})`; - const target = deref(bare, model); - return target === undefined ? undefined : hydration(target, expr, model, dateType); - } - if (bare.kind === 'array') { - const item = hydration(bare.items, '$item', model, dateType); - if (item === undefined) return undefined; - return `array_map(static fn ($item) => ${item}, ${expr})`; - } - if (bare.kind === 'record') { - const item = hydration(bare.value, '$item', model, dateType); - if (item === undefined) return undefined; - return `array_map(static fn ($item) => ${item}, ${expr})`; - } - return undefined; -} - -/** Typed value → wire value expression, or undefined when it serializes as-is. */ -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; -} - -/** - * 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: PhpPrinter, - name: string, - properties: PropertyModel[], - model: ApiModel, - dateType: DateType, - description?: string -): void { - // PHP requires defaulted parameters after required ones. - const ordered = [ - ...properties.filter((property) => property.required), - ...properties.filter((property) => !property.required), - ]; - printer.doc(className(name), description); - printer.line(`final class ${className(name)}`); - printer.block( - '{', - () => { - printer.block( - 'public function __construct(', - () => { - for (const property of ordered) { - const type = phpType(property.schema, model, dateType); - if (property.required) { - printer.line(`public ${type} ${'$'}${propertyName(property.name)},`); - } else { - const nullable = phpNullable(type); - printer.line(`public ${nullable} ${'$'}${propertyName(property.name)} = null,`); - } - } - }, - ') {' - ); - printer.line('}'); - printer.blank(); - - printer.line('public static function fromArray(array $data): self'); - printer.block( - '{', - () => { - printer.block( - 'return new self(', - () => { - for (const property of ordered) { - const raw = `$data[${phpString(property.name)}]`; - const typed = hydration(property.schema, raw, model, dateType); - const php = propertyName(property.name); - if (property.required) { - printer.line(`${php}: ${typed ?? raw},`); - } else if (typed === undefined) { - printer.line(`${php}: ${raw} ?? null,`); - } else { - printer.line(`${php}: isset(${raw}) ? ${typed} : null,`); - } - } - }, - ');' - ); - }, - '}' - ); - printer.blank(); - - printer.line('public function toArray(): array'); - printer.block( - '{', - () => { - printer.line('$data = [];'); - for (const property of ordered) { - const value = `$this->${propertyName(property.name)}`; - const wire = serialization(property.schema, value, model, dateType) ?? value; - if (property.required) { - printer.line(`$data[${phpString(property.name)}] = ${wire};`); - } else { - printer.block( - `if (${value} !== null) {`, - () => { - printer.line(`$data[${phpString(property.name)}] = ${wire};`); - }, - '}' - ); - } - } - printer.line('return $data;'); - }, - '}' - ); - }, - '}' - ); - printer.blank(); -} - -/** Render every named schema: classes (allOf flattened), native enums, union dispatchers. */ -export function renderPhpModels(model: ApiModel, dateType: DateType = 'string'): string { - const printer = new PhpPrinter(); - for (const { name, schema } of model.schemas) { - const asEnum = enumValues(schema); - if (asEnum !== undefined && (asEnum.scalar === 'string' || asEnum.scalar === 'integer')) { - const backing = asEnum.scalar === 'string' ? 'string' : 'int'; - printer.doc(className(name), schema.description); - printer.line(`enum ${className(name)}: ${backing}`); - printer.block( - '{', - () => { - // `1.5` and `15` fold to one pascal name; PHP rejects a duplicate case. - const members = uniqueIdentifiers( - asEnum.values.map((value) => String(value)), - { style: 'pascal', reserved: PHP } - ); - asEnum.values.forEach((value, index) => { - const literal = typeof value === 'string' ? phpString(value) : String(value); - printer.line(`case ${members[index]} = ${literal};`); - }); - }, - '}' - ); - printer.blank(); - continue; - } - if (schema.kind === 'object' || schema.kind === 'intersection') { - const flat = flattenAllOf(schema, model); - if (flat !== undefined) { - writeClass( - printer, - name, - flat.properties, - model, - dateType, - flat.description ?? schema.description - ); - continue; - } - } - const cases = discriminatorCases(schema, model); - if (cases !== undefined) { - const typeName = className(name); - const table = cases.cases - .map((entry) => `${entry.value} -> ${className(entry.schemaName)}`) - .join(', '); - printer.line( - `/** ${typeName} is a discriminated union (${phpString(cases.property)}): ${table}. */` - ); - printer.line(`function unmarshal${typeName}(array $data): mixed`); - printer.block( - '{', - () => { - printer.block( - `return match ($data[${phpString(cases.property)}] ?? null) {`, - () => { - for (const entry of cases.cases) { - printer.line( - `${phpString(entry.value)} => ${className(entry.schemaName)}::fromArray($data),` - ); - } - printer.line('default => $data,'); - }, - '};' - ); - }, - '}' - ); - printer.blank(); - continue; - } - // Everything else (plain unions, aliases, records) has no PHP declaration; - // references resolve to the underlying type via phpType. - } - return printer.toString(); -} - -function methodName(op: OperationModel): string { - return identifierFor(op.name, { style: 'camel', reserved: PHP }); -} - -/** - * The method name for every operation, unique across the client — PHP fatals on a - * redeclared method, and two operationIds may camel-case to one name (`get-user`, - * `getUser`). Keyed by the IR name, which the sanitizer already made unique. - */ -function methodIdents(model: ApiModel): Map { - const operations = model.services.flatMap((service) => service.operations); - const names = uniqueIdentifiers( - operations.map((op) => op.name), - { style: 'camel', reserved: PHP } - ); - return new Map(operations.map((op, index) => [op.name, names[index]])); -} - -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 { - const alternatives = securityRequirements(op, model).map((alternative) => { - const specs = alternative.map((spec) => - spec.kind === 'apiKey' - ? `['kind' => 'apiKey', 'scheme' => ${phpString(spec.scheme)}, 'name' => ${phpString(spec.name)}, 'in' => ${phpString(spec.in)}]` - : `['kind' => ${phpString(spec.kind)}, 'scheme' => ${phpString(spec.scheme)}]` - ); - return `[${specs.join(', ')}]`; - }); - if (alternatives.length === 0) return undefined; - return `[${alternatives.join(', ')}]`; -} - -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 - ? [ - `${isMultipartBody(op) ? 'array' : phpType(op.requestBody.schema, model, dateType)} ${'$'}body`, - ] - : []), - ...queryArgs.map(({ php, type }) => { - const nullable = phpNullable(type); - return `${nullable} ${'$'}${php} = null`; - }), - '?array $headers = null', - ...(includeBody && MUTATING.has(op.method.toLowerCase()) - ? ['?string $idempotencyKey = null'] - : []), - ]; - return { pathArgs, queryArgs, signature }; -} - -/** The shared prologue: resolve auth, build query/url, merge headers. */ -function writeRequestSetup(printer: PhpPrinter, op: OperationModel, args: MethodArgs): void { - printer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`); - printer.line( - "[$authHeaders, $query, $cookies] = resolveAuth($op['security'] ?? [], $this->config->auth);" - ); - for (const { php, wire, value } of args.queryArgs) { - printer.block( - `if (${'$'}${php} !== null) {`, - () => { - printer.line(`$query[${phpString(wire)}] = ${value};`); - }, - '}' - ); - } - const pathDict = args.pathArgs - .map(({ php, wire }) => `${phpString(wire)} => ${'$'}${php}`) - .join(', '); - printer.line(`$url = buildUrl($this->config->serverUrl, $op['path'], [${pathDict}]);`); - printer.line('$requestHeaders = array_merge($authHeaders, $headers ?? []);'); - printer.block( - 'if ($cookies !== []) {', - () => { - printer.line("$requestHeaders['Cookie'] = implode('; ', $cookies);"); - }, - '}' - ); -} - -/** 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: PhpPrinter, - op: OperationModel, - ident: string, - model: ApiModel, - dateType: DateType, - envelope = false -): void { - const args = methodArgs(op, model, true, dateType); - const sse = sseResponse(op); - const success = jsonSuccessSchema(op); - // Non-JSON success bodies (PDFs, images, octet streams) return the raw body string. - const rawBody = - sse === undefined && - success === undefined && - op.successResponses.some((response) => response.contentType !== ''); - const returnType = envelope - ? 'Envelope' - : sse !== undefined - ? '\\Generator' - : success !== undefined - ? phpType(success, model, dateType) - : rawBody - ? 'string' - : 'void'; - const name = envelope ? `${ident}WithHeaders` : ident; - const element = envelope ? undefined : phpElementType(success, model, dateType); - printer.doc( - name, - envelope - ? `Like ${ident}(), returning an Envelope with the declared response headers.` - : (op.summary ?? `${op.method.toUpperCase()} ${op.path}`), - element === undefined ? [] : [`@return ${element}[]`] - ); - printer.line(`public function ${name}(${args.signature.join(', ')}): ${returnType}`); - printer.block( - '{', - () => { - writeRequestSetup(printer, op, args); - if (sse !== undefined) { - const jsonData = sse.schema !== undefined && sse.schema.kind !== 'unknown'; - printer.line('$url = appendQuery($url, $query);'); - printer.block( - '$open = function (array $extraHeaders) use ($url, $requestHeaders): \\CurlHandle {', - () => { - printer.line('$handle = curl_init($url);'); - printer.line('$lines = [];'); - printer.block( - 'foreach (array_merge($requestHeaders, $extraHeaders) as $name => $value) {', - () => { - printer.line("$lines[] = $name . ': ' . $value;"); - }, - '}' - ); - printer.line('curl_setopt($handle, CURLOPT_HTTPHEADER, $lines);'); - printer.line('return $handle;'); - }, - '};' - ); - printer.line(`yield from iterSse($open, ${jsonData ? 'true' : 'false'});`); - return; - } - const request = [ - `'operationId' => $op['id']`, - `'method' => $op['method']`, - `'url' => $url`, - `'headers' => $requestHeaders`, - `'query' => $query`, - ]; - if (op.requestBody && isMultipartBody(op)) { - printer.line('[$contentType, $encoded] = toMultipart($body);'); - request.push(`'body' => $encoded`, `'contentType' => $contentType`); - } else if (op.requestBody) { - const wire = serialization(op.requestBody.schema, '$body', model, dateType) ?? '$body'; - printer.line(`$payload = json_encode(${wire});`); - request.push( - `'body' => $payload`, - `'contentType' => ${phpString(op.requestBody.contentType)}` - ); - } - if (MUTATING.has(op.method.toLowerCase()) && op.requestBody) { - request.push(`'idempotencyKey' => $idempotencyKey`); - } - printer.line(`$response = send($this->config, [${request.join(', ')}]);`); - printer.block( - "if ($response['status'] >= 400) {", - () => { - printer.line('throw apiErrorFrom($response);'); - }, - '}' - ); - const decoded = rawBody - ? "$response['body']" - : ((success === undefined - ? undefined - : hydration(success, 'decodeJson($response)', model, dateType)) ?? - 'decodeJson($response)'); - if (envelope) { - printer.line(`$data = ${decoded};`); - printer.line( - `return new Envelope(data: $data, headers: readEnvelopeHeaders($response, ${envelopeHeaderSpecs(op, model)}), status: $response['status']);` - ); - return; - } - if (rawBody) { - printer.line("return $response['body'];"); - return; - } - if (returnType === 'void') { - printer.line('decodeJson($response);'); - return; - } - printer.line(`return ${decoded};`); - }, - '}' - ); - printer.blank(); -} - -/** `Pages()` / `Items()` generators over the runtime's iterPages. */ -function writePhpPaginationWrappers( - printer: PhpPrinter, - op: OperationModel, - ident: string, - model: ApiModel, - dateType: DateType, - pageHydration: string | undefined, - itemHydration: string | undefined, - itemsPointer: string | undefined, - itemYield: string -): void { - const args = methodArgs(op, model, false, dateType); - const name = ident; - - const writeCall = () => { - printer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`); - printer.line('$base = [];'); - for (const { php, wire, value } of args.queryArgs) { - printer.block( - `if (${'$'}${php} !== null) {`, - () => { - printer.line(`$base[${phpString(wire)}] = ${value};`); - }, - '}' - ); - } - const pathDict = args.pathArgs - .map(({ php, wire }) => `${phpString(wire)} => ${'$'}${php}`) - .join(', '); - printer.block( - '$call = function (array $params) use ($op, $headers): array {', - () => { - printer.line( - "[$authHeaders, $authQuery, $cookies] = resolveAuth($op['security'] ?? [], $this->config->auth);" - ); - printer.line(`$url = buildUrl($this->config->serverUrl, $op['path'], [${pathDict}]);`); - printer.line('$requestHeaders = array_merge($authHeaders, $headers ?? []);'); - printer.block( - 'if ($cookies !== []) {', - () => { - printer.line("$requestHeaders['Cookie'] = implode('; ', $cookies);"); - }, - '}' - ); - printer.line( - "$response = send($this->config, ['operationId' => $op['id'], 'method' => $op['method'], 'url' => $url, 'headers' => $requestHeaders, 'query' => array_merge($params, $authQuery)]);" - ); - printer.block( - "if ($response['status'] >= 400) {", - () => { - printer.line('throw apiErrorFrom($response);'); - }, - '}' - ); - printer.line('return [decodeJson($response), $response];'); - }, - '};' - ); - }; - - const pageType = phpType(jsonSuccessSchema(op) ?? { kind: 'unknown' }, model, dateType); - const pageYield = pageType === 'mixed' ? 'mixed' : pageType; - printer.line('/**'); - printer.line(` * ${name} response pages, following the pagination rule automatically.`); - printer.line(' *'); - printer.line(` * @return \\Generator`); - printer.line(' */'); - printer.line(`public function ${name}Pages(${args.signature.join(', ')}): \\Generator`); - printer.block( - '{', - () => { - writeCall(); - printer.block( - "foreach (iterPages($call, $op['pagination'], $base) as $page) {", - () => { - printer.line(`yield ${pageHydration ?? '$page'};`); - }, - '}' - ); - }, - '}' - ); - printer.blank(); - - printer.line('/**'); - printer.line(` * The items of every ${name} page.`); - printer.line(' *'); - printer.line(` * @return \\Generator`); - printer.line(' */'); - printer.line(`public function ${name}Items(${args.signature.join(', ')}): \\Generator`); - printer.block( - '{', - () => { - writeCall(); - printer.block( - "foreach (iterPages($call, $op['pagination'], $base) as $page) {", - () => { - printer.line(`$items = resolvePointer($page, ${phpString(itemsPointer ?? '')});`); - printer.block( - 'foreach (is_array($items) ? $items : [] as $item) {', - () => { - printer.line(`yield ${itemHydration ?? '$item'};`); - }, - '}' - ); - }, - '}' - ); - }, - '}' - ); - printer.blank(); -} - -/** The server URL as a PHP expression: literals concatenated with declared-variable args. */ -function serverUrlExpression(server: ServerModel): string { - const parts = serverUrlParts(server).map((part) => - part.kind === 'literal' ? phpString(part.value) : `${'$'}${propertyName(part.name)}` - ); - return parts.join(' . '); -} - -/** One static method per declared server; server variables become named string arguments. */ -function writeServers(printer: PhpPrinter, model: ApiModel): void { - const servers = model.servers ?? []; - if (servers.length === 0) return; - const usedNames = new Set(); - printer.line( - '/** The declared servers; variables default to the values from the description. */' - ); - printer.line('final class Servers'); - printer.block( - '{', - () => { - servers.forEach((server, index) => { - let name = identifierFor(server.description ?? `server${index + 1}`, { - style: 'camel', - reserved: PHP, - }); - if (usedNames.has(name)) name = `${name}${index + 1}`; - usedNames.add(name); - const params = server.variables.map( - (variable) => - `string ${'$'}${propertyName(variable.name)} = ${phpString(variable.default)}` - ); - if (index > 0) printer.blank(); - printer.line(`public static function ${name}(${params.join(', ')}): string`); - printer.block( - '{', - () => { - printer.line(`return ${serverUrlExpression(server)};`); - }, - '}' - ); - }); - }, - '}' - ); - printer.blank(); -} +import { writeServers } from './client.js'; +import { phpPaginationLiteral, phpSecurityLiteral } from './descriptor.js'; +import { hydration, renderPhpModels } from './models.js'; +import { methodIdents, methodName, PHP, phpString, propertyName } from './naming.js'; +import { writePhpMethod } from './operations.js'; +import { writePhpPaginationWrappers } from './pagination.js'; +import { phpType } from './types.js'; + +export { renderPhpModels } from './models.js'; +export { phpType } from './types.js'; /** Drop the standalone header ( candidate.name === name); + return named !== undefined && discriminatorCases(named.schema, model) !== undefined; +} + +/** Wire value → typed value expression, or undefined when the raw value is already right. */ +export function hydration( + schema: SchemaModel, + expr: string, + model: ApiModel, + // Required on purpose: a defaulted `'string'` let a call site forget it, and the method + // then returned a raw string where its own signature declared `\DateTimeImmutable`. + dateType: DateType +): string | undefined { + const bare = unwrapNullable(schema); + if (dateType === 'Date' && bare.kind === 'scalar' && bare.scalar === 'string') { + if (isDateFormat(bare)) return `new \\DateTimeImmutable(${expr})`; + } + if (bare.kind === 'omit') + return hydration({ kind: 'ref', name: bare.base }, expr, model, dateType); + if (bare.kind === 'ref') { + const kind = classify(bare.name, model); + if (kind === 'class') return `${className(bare.name)}::fromArray(${expr})`; + if (kind === 'enum') return `${className(bare.name)}::from(${expr})`; + if (isDiscriminatedUnion(bare.name, model)) return `unmarshal${className(bare.name)}(${expr})`; + const target = deref(bare, model); + return target === undefined ? undefined : hydration(target, expr, model, dateType); + } + if (bare.kind === 'array') { + const item = hydration(bare.items, '$item', model, dateType); + if (item === undefined) return undefined; + return `array_map(static fn ($item) => ${item}, ${expr})`; + } + if (bare.kind === 'record') { + const item = hydration(bare.value, '$item', model, dateType); + if (item === undefined) return undefined; + return `array_map(static fn ($item) => ${item}, ${expr})`; + } + return undefined; +} + +/** Typed value → wire value expression, or undefined when it serializes as-is. */ +export function serialization( + schema: SchemaModel, + expr: string, + model: ApiModel, + dateType: DateType = 'string' +): string | undefined { + const bare = unwrapNullable(schema); + if (dateType === 'Date' && bare.kind === 'scalar' && bare.scalar === 'string') { + // A date-only value must not gain a time component on the way out. + if (bare.metadata?.format === 'date') return `${expr}->format('Y-m-d')`; + if (bare.metadata?.format === 'date-time') { + return `${expr}->format(\\DateTimeInterface::ATOM)`; + } + } + if (bare.kind === 'omit') { + return serialization({ kind: 'ref', name: bare.base }, expr, model, dateType); + } + if (bare.kind === 'ref') { + const kind = classify(bare.name, model); + if (kind === 'class') return `${expr}->toArray()`; + if (kind === 'enum') return `${expr}->value`; + // A union value may be a hydrated member instance or a raw (default-case) array. + if (isDiscriminatedUnion(bare.name, model)) { + return `is_object(${expr}) ? ${expr}->toArray() : ${expr}`; + } + const target = deref(bare, model); + return target === undefined ? undefined : serialization(target, expr, model, dateType); + } + if (bare.kind === 'array' || bare.kind === 'record') { + const inner = bare.kind === 'array' ? bare.items : bare.value; + const item = serialization(inner, '$item', model, dateType); + if (item === undefined) return undefined; + return `array_map(static fn ($item) => ${item}, ${expr})`; + } + return undefined; +} + +function writeClass( + printer: PhpPrinter, + name: string, + properties: PropertyModel[], + model: ApiModel, + dateType: DateType, + description?: string +): void { + // PHP requires defaulted parameters after required ones. + const ordered = [ + ...properties.filter((property) => property.required), + ...properties.filter((property) => !property.required), + ]; + printer.doc(className(name), description); + printer.line(`final class ${className(name)}`); + printer.block( + '{', + () => { + printer.block( + 'public function __construct(', + () => { + for (const property of ordered) { + const type = phpType(property.schema, model, dateType); + if (property.required) { + printer.line(`public ${type} ${'$'}${propertyName(property.name)},`); + } else { + const nullable = phpNullable(type); + printer.line(`public ${nullable} ${'$'}${propertyName(property.name)} = null,`); + } + } + }, + ') {' + ); + printer.line('}'); + printer.blank(); + + printer.line('public static function fromArray(array $data): self'); + printer.block( + '{', + () => { + printer.block( + 'return new self(', + () => { + for (const property of ordered) { + const raw = `$data[${phpString(property.name)}]`; + const typed = hydration(property.schema, raw, model, dateType); + const php = propertyName(property.name); + if (property.required) { + printer.line(`${php}: ${typed ?? raw},`); + } else if (typed === undefined) { + printer.line(`${php}: ${raw} ?? null,`); + } else { + printer.line(`${php}: isset(${raw}) ? ${typed} : null,`); + } + } + }, + ');' + ); + }, + '}' + ); + printer.blank(); + + printer.line('public function toArray(): array'); + printer.block( + '{', + () => { + printer.line('$data = [];'); + for (const property of ordered) { + const value = `$this->${propertyName(property.name)}`; + const wire = serialization(property.schema, value, model, dateType) ?? value; + if (property.required) { + printer.line(`$data[${phpString(property.name)}] = ${wire};`); + } else { + printer.block( + `if (${value} !== null) {`, + () => { + printer.line(`$data[${phpString(property.name)}] = ${wire};`); + }, + '}' + ); + } + } + printer.line('return $data;'); + }, + '}' + ); + }, + '}' + ); + printer.blank(); +} + +/** Render every named schema: classes (allOf flattened), native enums, union dispatchers. */ +export function renderPhpModels(model: ApiModel, dateType: DateType = 'string'): string { + const printer = new PhpPrinter(); + for (const { name, schema } of model.schemas) { + const asEnum = enumValues(schema); + if (asEnum !== undefined && (asEnum.scalar === 'string' || asEnum.scalar === 'integer')) { + const backing = asEnum.scalar === 'string' ? 'string' : 'int'; + printer.doc(className(name), schema.description); + printer.line(`enum ${className(name)}: ${backing}`); + printer.block( + '{', + () => { + // `1.5` and `15` fold to one pascal name; PHP rejects a duplicate case. + const members = uniqueIdentifiers( + asEnum.values.map((value) => String(value)), + { style: 'pascal', reserved: PHP } + ); + asEnum.values.forEach((value, index) => { + const literal = typeof value === 'string' ? phpString(value) : String(value); + printer.line(`case ${members[index]} = ${literal};`); + }); + }, + '}' + ); + printer.blank(); + continue; + } + if (schema.kind === 'object' || schema.kind === 'intersection') { + const flat = flattenAllOf(schema, model); + if (flat !== undefined) { + writeClass( + printer, + name, + flat.properties, + model, + dateType, + flat.description ?? schema.description + ); + continue; + } + } + const cases = discriminatorCases(schema, model); + if (cases !== undefined) { + const typeName = className(name); + const table = cases.cases + .map((entry) => `${entry.value} -> ${className(entry.schemaName)}`) + .join(', '); + printer.line( + `/** ${typeName} is a discriminated union (${phpString(cases.property)}): ${table}. */` + ); + printer.line(`function unmarshal${typeName}(array $data): mixed`); + printer.block( + '{', + () => { + printer.block( + `return match ($data[${phpString(cases.property)}] ?? null) {`, + () => { + for (const entry of cases.cases) { + printer.line( + `${phpString(entry.value)} => ${className(entry.schemaName)}::fromArray($data),` + ); + } + printer.line('default => $data,'); + }, + '};' + ); + }, + '}' + ); + printer.blank(); + continue; + } + // Everything else (plain unions, aliases, records) has no PHP declaration; + // references resolve to the underlying type via phpType. + } + return printer.toString(); +} diff --git a/packages/client-generator/src/generators/php/naming.ts b/packages/client-generator/src/generators/php/naming.ts new file mode 100644 index 0000000000..3fe46ee17f --- /dev/null +++ b/packages/client-generator/src/generators/php/naming.ts @@ -0,0 +1,42 @@ +// The `naming` stage: the shared printer/naming instance, the string escaper, and +// the collision-free class/property/method identifiers every other stage builds on. + +import { identifierFor, RESERVED_WORDS, uniqueIdentifiers } from '../../authoring/index.js'; +import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; +import { PhpPrinter } from '../../printers/php.js'; + +export const PHP = RESERVED_WORDS.php; + +// Naming and escaping delegate to the printer — one implementation, one policy. +export const naming = new PhpPrinter(); + +export function className(name: string): string { + return naming.typeName(name); +} + +export function propertyName(name: string): string { + return naming.memberName(name); +} + +/** `'…'` with backslashes and quotes escaped — safe for any spec-supplied text. */ +export function phpString(value: string): string { + return naming.string(value); +} + +export function methodName(op: OperationModel): string { + return identifierFor(op.name, { style: 'camel', reserved: PHP }); +} + +/** + * The method name for every operation, unique across the client — PHP fatals on a + * redeclared method, and two operationIds may camel-case to one name (`get-user`, + * `getUser`). Keyed by the IR name, which the sanitizer already made unique. + */ +export function methodIdents(model: ApiModel): Map { + const operations = model.services.flatMap((service) => service.operations); + const names = uniqueIdentifiers( + operations.map((op) => op.name), + { style: 'camel', reserved: PHP } + ); + return new Map(operations.map((op, index) => [op.name, names[index]])); +} diff --git a/packages/client-generator/src/generators/php/operations.ts b/packages/client-generator/src/generators/php/operations.ts new file mode 100644 index 0000000000..3eea6def6b --- /dev/null +++ b/packages/client-generator/src/generators/php/operations.ts @@ -0,0 +1,226 @@ +// The `operations` stage: one typed request method per operation, plus the +// argument planning and request prologue it shares with the pagination wrappers. + +import { + isMultipartBody, + jsonSuccessSchema, + sseResponse, + uniqueIdentifiers, + type DateType, +} from '../../authoring/index.js'; +import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; +import type { PhpPrinter } from '../../printers/php.js'; +import { envelopeHeaderSpecs } from './descriptor.js'; +import { hydration, serialization } from './models.js'; +import { PHP, phpString } from './naming.js'; +import { phpElementType, phpNullable, phpType } from './types.js'; + +const MUTATING = new Set(['post', 'put', 'patch']); + +type MethodArgs = { + pathArgs: Array<{ php: string; wire: string; type: string }>; + /** `value` is the expression to send: a date object formats itself, everything else is the variable. */ + queryArgs: Array<{ php: string; wire: string; type: string; value: string }>; + signature: string[]; +}; + +/** + * The argument names a request method declares beside its parameters. A parameter named + * after one of them takes a suffixed variable instead, so the slot keeps its meaning. + */ +const SIGNATURE_ARG_SLOTS = ['body', 'headers', 'idempotencyKey']; + +export function methodArgs( + op: OperationModel, + model: ApiModel, + includeBody: boolean, + dateType: DateType +): MethodArgs { + // Each parameter is its own argument, so path and query names share one namespace with + // the slots this signature declares itself (`$body`, `$headers`, `$idempotencyKey`). + // A repeat moves aside (`$id`, `$id_2`): PHP rejects a redefined parameter outright, and + // a description may legally use one name in two locations. + const names = uniqueIdentifiers( + [...op.pathParams, ...op.queryParams].map((param) => param.name), + { style: 'camel', reserved: PHP, taken: SIGNATURE_ARG_SLOTS } + ); + const pathArgs = op.pathParams.map((param, index) => ({ + php: names[index], + wire: param.name, + type: phpType(param.schema, model, dateType), + })); + const queryArgs = op.queryParams.map((param, index) => { + const php = names[op.pathParams.length + index]; + return { + php, + wire: param.name, + type: phpType(param.schema, model, dateType), + value: serialization(param.schema, `${'$'}${php}`, model, dateType) ?? `${'$'}${php}`, + }; + }); + const signature = [ + ...pathArgs.map(({ php, type }) => `${type} ${'$'}${php}`), + ...(includeBody && op.requestBody + ? [ + `${isMultipartBody(op) ? 'array' : phpType(op.requestBody.schema, model, dateType)} ${'$'}body`, + ] + : []), + ...queryArgs.map(({ php, type }) => { + const nullable = phpNullable(type); + return `${nullable} ${'$'}${php} = null`; + }), + '?array $headers = null', + ...(includeBody && MUTATING.has(op.method.toLowerCase()) + ? ['?string $idempotencyKey = null'] + : []), + ]; + return { pathArgs, queryArgs, signature }; +} + +/** The shared prologue: resolve auth, build query/url, merge headers. */ +function writeRequestSetup(printer: PhpPrinter, op: OperationModel, args: MethodArgs): void { + printer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`); + printer.line( + "[$authHeaders, $query, $cookies] = resolveAuth($op['security'] ?? [], $this->config->auth);" + ); + for (const { php, wire, value } of args.queryArgs) { + printer.block( + `if (${'$'}${php} !== null) {`, + () => { + printer.line(`$query[${phpString(wire)}] = ${value};`); + }, + '}' + ); + } + const pathDict = args.pathArgs + .map(({ php, wire }) => `${phpString(wire)} => ${'$'}${php}`) + .join(', '); + printer.line(`$url = buildUrl($this->config->serverUrl, $op['path'], [${pathDict}]);`); + printer.line('$requestHeaders = array_merge($authHeaders, $headers ?? []);'); + printer.block( + 'if ($cookies !== []) {', + () => { + printer.line("$requestHeaders['Cookie'] = implode('; ', $cookies);"); + }, + '}' + ); +} + +export function writePhpMethod( + printer: PhpPrinter, + op: OperationModel, + ident: string, + model: ApiModel, + dateType: DateType, + envelope = false +): void { + const args = methodArgs(op, model, true, dateType); + const sse = sseResponse(op); + const success = jsonSuccessSchema(op); + // Non-JSON success bodies (PDFs, images, octet streams) return the raw body string. + const rawBody = + sse === undefined && + success === undefined && + op.successResponses.some((response) => response.contentType !== ''); + const returnType = envelope + ? 'Envelope' + : sse !== undefined + ? '\\Generator' + : success !== undefined + ? phpType(success, model, dateType) + : rawBody + ? 'string' + : 'void'; + const name = envelope ? `${ident}WithHeaders` : ident; + const element = envelope ? undefined : phpElementType(success, model, dateType); + printer.doc( + name, + envelope + ? `Like ${ident}(), returning an Envelope with the declared response headers.` + : (op.summary ?? `${op.method.toUpperCase()} ${op.path}`), + element === undefined ? [] : [`@return ${element}[]`] + ); + printer.line(`public function ${name}(${args.signature.join(', ')}): ${returnType}`); + printer.block( + '{', + () => { + writeRequestSetup(printer, op, args); + if (sse !== undefined) { + const jsonData = sse.schema !== undefined && sse.schema.kind !== 'unknown'; + printer.line('$url = appendQuery($url, $query);'); + printer.block( + '$open = function (array $extraHeaders) use ($url, $requestHeaders): \\CurlHandle {', + () => { + printer.line('$handle = curl_init($url);'); + printer.line('$lines = [];'); + printer.block( + 'foreach (array_merge($requestHeaders, $extraHeaders) as $name => $value) {', + () => { + printer.line("$lines[] = $name . ': ' . $value;"); + }, + '}' + ); + printer.line('curl_setopt($handle, CURLOPT_HTTPHEADER, $lines);'); + printer.line('return $handle;'); + }, + '};' + ); + printer.line(`yield from iterSse($open, ${jsonData ? 'true' : 'false'});`); + return; + } + const request = [ + `'operationId' => $op['id']`, + `'method' => $op['method']`, + `'url' => $url`, + `'headers' => $requestHeaders`, + `'query' => $query`, + ]; + if (op.requestBody && isMultipartBody(op)) { + printer.line('[$contentType, $encoded] = toMultipart($body);'); + request.push(`'body' => $encoded`, `'contentType' => $contentType`); + } else if (op.requestBody) { + const wire = serialization(op.requestBody.schema, '$body', model, dateType) ?? '$body'; + printer.line(`$payload = json_encode(${wire});`); + request.push( + `'body' => $payload`, + `'contentType' => ${phpString(op.requestBody.contentType)}` + ); + } + if (MUTATING.has(op.method.toLowerCase()) && op.requestBody) { + request.push(`'idempotencyKey' => $idempotencyKey`); + } + printer.line(`$response = send($this->config, [${request.join(', ')}]);`); + printer.block( + "if ($response['status'] >= 400) {", + () => { + printer.line('throw apiErrorFrom($response);'); + }, + '}' + ); + const decoded = rawBody + ? "$response['body']" + : ((success === undefined + ? undefined + : hydration(success, 'decodeJson($response)', model, dateType)) ?? + 'decodeJson($response)'); + if (envelope) { + printer.line(`$data = ${decoded};`); + printer.line( + `return new Envelope(data: $data, headers: readEnvelopeHeaders($response, ${envelopeHeaderSpecs(op, model)}), status: $response['status']);` + ); + return; + } + if (rawBody) { + printer.line("return $response['body'];"); + return; + } + if (returnType === 'void') { + printer.line('decodeJson($response);'); + return; + } + printer.line(`return ${decoded};`); + }, + '}' + ); + printer.blank(); +} diff --git a/packages/client-generator/src/generators/php/pagination.ts b/packages/client-generator/src/generators/php/pagination.ts new file mode 100644 index 0000000000..d63e04edcb --- /dev/null +++ b/packages/client-generator/src/generators/php/pagination.ts @@ -0,0 +1,124 @@ +// The `pagination` stage: the `Pages()` / `Items()` generator methods +// over the runtime's iterPages. + +import { jsonSuccessSchema, type DateType } from '../../authoring/index.js'; +import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; +import type { PhpPrinter } from '../../printers/php.js'; +import { phpString } from './naming.js'; +import { methodArgs } from './operations.js'; +import { phpType } from './types.js'; + +/** `Pages()` / `Items()` generators over the runtime's iterPages. */ +export function writePhpPaginationWrappers( + printer: PhpPrinter, + op: OperationModel, + ident: string, + model: ApiModel, + dateType: DateType, + pageHydration: string | undefined, + itemHydration: string | undefined, + itemsPointer: string | undefined, + itemYield: string +): void { + const args = methodArgs(op, model, false, dateType); + const name = ident; + + const writeCall = () => { + printer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`); + printer.line('$base = [];'); + for (const { php, wire, value } of args.queryArgs) { + printer.block( + `if (${'$'}${php} !== null) {`, + () => { + printer.line(`$base[${phpString(wire)}] = ${value};`); + }, + '}' + ); + } + const pathDict = args.pathArgs + .map(({ php, wire }) => `${phpString(wire)} => ${'$'}${php}`) + .join(', '); + printer.block( + '$call = function (array $params) use ($op, $headers): array {', + () => { + printer.line( + "[$authHeaders, $authQuery, $cookies] = resolveAuth($op['security'] ?? [], $this->config->auth);" + ); + printer.line(`$url = buildUrl($this->config->serverUrl, $op['path'], [${pathDict}]);`); + printer.line('$requestHeaders = array_merge($authHeaders, $headers ?? []);'); + printer.block( + 'if ($cookies !== []) {', + () => { + printer.line("$requestHeaders['Cookie'] = implode('; ', $cookies);"); + }, + '}' + ); + printer.line( + "$response = send($this->config, ['operationId' => $op['id'], 'method' => $op['method'], 'url' => $url, 'headers' => $requestHeaders, 'query' => array_merge($params, $authQuery)]);" + ); + printer.block( + "if ($response['status'] >= 400) {", + () => { + printer.line('throw apiErrorFrom($response);'); + }, + '}' + ); + printer.line('return [decodeJson($response), $response];'); + }, + '};' + ); + }; + + const pageType = phpType(jsonSuccessSchema(op) ?? { kind: 'unknown' }, model, dateType); + const pageYield = pageType === 'mixed' ? 'mixed' : pageType; + printer.line('/**'); + printer.line(` * ${name} response pages, following the pagination rule automatically.`); + printer.line(' *'); + printer.line(` * @return \\Generator`); + printer.line(' */'); + printer.line(`public function ${name}Pages(${args.signature.join(', ')}): \\Generator`); + printer.block( + '{', + () => { + writeCall(); + printer.block( + "foreach (iterPages($call, $op['pagination'], $base) as $page) {", + () => { + printer.line(`yield ${pageHydration ?? '$page'};`); + }, + '}' + ); + }, + '}' + ); + printer.blank(); + + printer.line('/**'); + printer.line(` * The items of every ${name} page.`); + printer.line(' *'); + printer.line(` * @return \\Generator`); + printer.line(' */'); + printer.line(`public function ${name}Items(${args.signature.join(', ')}): \\Generator`); + printer.block( + '{', + () => { + writeCall(); + printer.block( + "foreach (iterPages($call, $op['pagination'], $base) as $page) {", + () => { + printer.line(`$items = resolvePointer($page, ${phpString(itemsPointer ?? '')});`); + printer.block( + 'foreach (is_array($items) ? $items : [] as $item) {', + () => { + printer.line(`yield ${itemHydration ?? '$item'};`); + }, + '}' + ); + }, + '}' + ); + }, + '}' + ); + printer.blank(); +} diff --git a/packages/client-generator/src/generators/php/types.ts b/packages/client-generator/src/generators/php/types.ts new file mode 100644 index 0000000000..71748ed658 --- /dev/null +++ b/packages/client-generator/src/generators/php/types.ts @@ -0,0 +1,142 @@ +// The `types` stage: the PHP type declaration for a schema, its nullable and +// union forms, and the element type PHP's own syntax erases. + +import { + deref, + enumValues, + flattenAllOf, + isNullable, + unwrapNullable, + type DateType, +} from '../../authoring/index.js'; +import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; +import { className } from './naming.js'; + +/** What a named schema renders as: a class, a native enum, or nothing (alias). */ +export function classify(name: string, model: ApiModel): 'class' | 'enum' | 'other' { + const named = model.schemas.find((candidate) => candidate.name === name); + if (named === undefined) return 'other'; + const schema = named.schema; + const asEnum = enumValues(schema); + if (asEnum !== undefined && (asEnum.scalar === 'string' || asEnum.scalar === 'integer')) { + return 'enum'; + } + if ( + (schema.kind === 'object' || schema.kind === 'intersection') && + flattenAllOf(schema, model) !== undefined + ) { + return 'class'; + } + return 'other'; +} + +/** The PHP type declaration for a schema (arrays and unions widen to array/mixed). */ +export function phpType( + schema: SchemaModel, + model: ApiModel, + dateType: DateType = 'string' +): string { + if (isNullable(schema)) { + const inner = phpType(unwrapNullable(schema), model, dateType); + return phpNullable(inner); + } + switch (schema.kind) { + case 'scalar': + // Under `dateType: Date`, date and date-time become DateTimeImmutable — PHP's + // immutable date object parses and formats both wire shapes. + if (dateType === 'Date' && schema.scalar === 'string' && isDateFormat(schema)) { + return '\\DateTimeImmutable'; + } + return { string: 'string', integer: 'int', number: 'float', boolean: 'bool' }[schema.scalar]; + case 'array': + case 'record': + return 'array'; + case 'ref': { + const kind = classify(schema.name, model); + if (kind === 'class' || kind === 'enum') return className(schema.name); + const target = deref(schema, model); + return target === undefined ? 'mixed' : phpType(target, model, dateType); + } + case 'enum': + // Anonymous (inline) enums keep the wire scalar; only NAMED enums get types. + return { string: 'string', integer: 'int', number: 'float', boolean: 'bool' }[schema.scalar]; + case 'literal': + return typeof schema.value === 'string' + ? 'string' + : typeof schema.value === 'boolean' + ? 'bool' + : 'float'; + case 'omit': + // PHP has no Omit; the base class is the honest annotation. + return className(schema.base); + case 'union': + return phpUnionType(schema.members, model, dateType); + case 'null': + case 'object': + case 'intersection': + case 'unknown': + return 'mixed'; + } +} + +/** `date` or `date-time` — the two formats `dateType: Date` turns into objects. */ +export function isDateFormat(schema: SchemaModel): boolean { + const format = schema.metadata?.format; + return format === 'date' || format === 'date-time'; +} + +/** + * The nullable form of a PHP type. `?T` for a single type, `A|B|null` for a union — PHP + * forbids mixing `?` with `|`, and `mixed` already includes null. + */ +export function phpNullable(type: string): string { + if (type === 'mixed' || type.startsWith('?') || type.endsWith('|null')) return type; + return type.includes('|') ? `${type}|null` : `?${type}`; +} + +/** + * A union as a native PHP 8.1 union type (`int|string`, `PromotionType|array`). Rich list + * filters are usually unions, and collapsing them to `mixed` throws away the typing that + * makes the SDK worth generating. `mixed` cannot be a union member, so a member without a + * PHP type of its own (inline object, intersection, unknown) forces the whole union to + * `mixed`. Members that map to the same PHP type collapse to one. + */ +export function phpUnionType(members: SchemaModel[], model: ApiModel, dateType: DateType): string { + const rendered: string[] = []; + for (const member of members) { + // `null` is handled by the caller's nullability check, never as a member here. + if (member.kind === 'null') continue; + const type = phpType(member, model, dateType); + if (type === 'mixed') return 'mixed'; + // A nullable member inside a union contributes its bare type plus null. + const bare = type.startsWith('?') ? type.slice(1) : type; + if (!rendered.includes(bare)) rendered.push(bare); + if (type.startsWith('?') && !rendered.includes('null')) rendered.push('null'); + } + if (rendered.length === 0) return 'mixed'; + return rendered.join('|'); +} + +/** + * The element type behind a PHP type that erases it. `array` and `\Generator` are as + * specific as PHP's syntax gets, so the docblock carries what they hold — that is what + * static analysis and readers actually go by. + */ +export function phpElementType( + schema: SchemaModel | undefined, + model: ApiModel, + dateType: DateType +): string | undefined { + if (schema === undefined) return undefined; + const bare = unwrapNullable(schema); + if (bare.kind === 'ref') { + const target = deref(bare, model); + // A named schema that IS an array (a collection alias) keeps its element type. + return classify(bare.name, model) === 'other' + ? phpElementType(target, model, dateType) + : undefined; + } + if (bare.kind !== 'array') return undefined; + const element = phpType(bare.items, model, dateType); + return element === 'mixed' ? undefined : element; +} From eac41e17aa18af5c47fffab433c618c9d4962b35 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 22:28:57 +0300 Subject: [PATCH 26/35] refactor: language generator folders import their own package by specifier, resolved to src by tsconfig paths and a vitest alias --- .../__tests__/language-dogfooding.test.ts | 23 +++++++++---------- .../src/generators/go/client.ts | 11 ++++++--- .../src/generators/go/descriptor.ts | 9 ++++++-- .../src/generators/go/index.ts | 15 +++++++----- .../src/generators/go/models.ts | 10 ++++---- .../src/generators/go/naming.ts | 10 +++++--- .../src/generators/go/operations.ts | 15 ++++++------ .../src/generators/go/pagination.ts | 6 ++--- .../src/generators/go/types.ts | 10 +++++--- .../src/generators/php/client.ts | 11 ++++++--- .../src/generators/php/descriptor.ts | 8 ++++--- .../src/generators/php/index.ts | 15 +++++++----- .../src/generators/php/models.ts | 15 ++++++------ .../src/generators/php/naming.ts | 11 ++++++--- .../src/generators/php/operations.ts | 10 ++++---- .../src/generators/php/pagination.ts | 11 ++++++--- .../src/generators/php/types.ts | 8 ++++--- .../src/generators/python/client.ts | 10 ++++---- .../src/generators/python/descriptor.ts | 6 +++-- .../src/generators/python/index.ts | 18 +++++++++++---- .../src/generators/python/models.ts | 10 ++++---- .../src/generators/python/naming.ts | 10 +++++--- .../src/generators/python/operations.ts | 10 ++++---- .../src/generators/python/pagination.ts | 11 ++++++--- .../src/generators/python/types.ts | 9 ++++++-- packages/client-generator/src/plugin.ts | 11 ++++++++- tsconfig.json | 10 +++++++- vitest.config.ts | 22 ++++++++++++++++++ 28 files changed, 219 insertions(+), 106 deletions(-) diff --git a/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts b/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts index adc96fc46e..7a497e2ae4 100644 --- a/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts +++ b/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts @@ -4,16 +4,13 @@ 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. +// toolkit only, through the SAME package specifiers an ejected copy carries (a +// tsconfig `paths` entry resolves them to src). 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 SHARED_SPECIFIERS = [ - '../../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 + '@redocly/client-generator', // the neutral toolkit + the IR types + the generator contract + '@redocly/client-generator/runtime-sources', // pure embedded strings, generated at prepare time ]; describe.each(['python', 'go', 'php'])('%s folder dogfooding invariant', (language) => { @@ -22,9 +19,11 @@ describe.each(['python', 'go', 'php'])('%s folder dogfooding invariant', (langua const stageFiles = readdirSync(folder).filter((name) => name.endsWith('.ts')); expect(stageFiles.length).toBeGreaterThan(0); // A generator's sharing tiers (ADR-0020): the neutral toolkit, its OWN language - // printer — never another language's — the runtime sources, the contract, and - // its own stage files. - const allowed = new Set([...SHARED_SPECIFIERS, `../../printers/${language}.js`]); + // printer — never another language's — the runtime sources, and its own stage files. + const allowed = new Set([ + ...SHARED_SPECIFIERS, + `@redocly/client-generator/printers/${language}`, + ]); for (const name of stageFiles) { const source = readFileSync(resolve(folder, name), 'utf-8'); const specifiers = [...source.matchAll(/from '([^']+)'/g)].map((match) => match[1]); diff --git a/packages/client-generator/src/generators/go/client.ts b/packages/client-generator/src/generators/go/client.ts index ee6aca7618..5a5e647f40 100644 --- a/packages/client-generator/src/generators/go/client.ts +++ b/packages/client-generator/src/generators/go/client.ts @@ -1,8 +1,13 @@ // The `client` stage: one `URL` function per declared server. -import { identifierFor, serverUrlParts } from '../../authoring/index.js'; -import type { ApiModel, ServerModel } from '../../intermediate-representation/model.js'; -import { exported, type GoPrinter } from '../../printers/go.js'; +import { + type ApiModel, + identifierFor, + type ServerModel, + serverUrlParts, +} from '@redocly/client-generator'; +import { exported, type GoPrinter } from '@redocly/client-generator/printers/go'; + import { GO, naming } from './naming.js'; /** The server URL as a Go expression: literals concatenated with declared-variable args. */ diff --git a/packages/client-generator/src/generators/go/descriptor.ts b/packages/client-generator/src/generators/go/descriptor.ts index 669195c072..abf3dfe3e0 100644 --- a/packages/client-generator/src/generators/go/descriptor.ts +++ b/packages/client-generator/src/generators/go/descriptor.ts @@ -1,8 +1,13 @@ // The `descriptor` stage: the operations-table composite literals — security // OR-alternatives and the pagination spec. -import { securityRequirements, type NeutralPaginationRule } from '../../authoring/index.js'; -import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; +import { + type ApiModel, + type NeutralPaginationRule, + type OperationModel, + securityRequirements, +} from '@redocly/client-generator'; + import { naming } from './naming.js'; /** Go composite literal for one operation's security OR-alternatives. */ diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index 5154d1be63..661eac3081 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -6,17 +6,20 @@ // One file per pipeline stage (ADR-0020); this entry assembles them. import { + type CodeSample, + type Generator, identifierFor, jsonSuccessSchema, + type NeutralPaginationRule, + type OperationModel, paginationItemSchema, renderReferencePage, + type SampleContext, sseResponse, - type NeutralPaginationRule, -} from '../../authoring/index.js'; -import { GO_RUNTIME_SOURCE } from '../../emitters/go-runtime-sources.js'; -import type { OperationModel } from '../../intermediate-representation/model.js'; -import { exported, GoPrinter } from '../../printers/go.js'; -import type { CodeSample, Generator, SampleContext } from '../types.js'; +} from '@redocly/client-generator'; +import { exported, GoPrinter } from '@redocly/client-generator/printers/go'; +import { GO_RUNTIME_SOURCE } from '@redocly/client-generator/runtime-sources'; + import { writeGoServers } from './client.js'; import { goPaginationLiteral, goSecurityLiteral } from './descriptor.js'; import { renderGoModels } from './models.js'; diff --git a/packages/client-generator/src/generators/go/models.ts b/packages/client-generator/src/generators/go/models.ts index 65f45c79c8..2a3bb0294b 100644 --- a/packages/client-generator/src/generators/go/models.ts +++ b/packages/client-generator/src/generators/go/models.ts @@ -2,14 +2,16 @@ // (allOf flattened), and discriminated unions with unmarshal dispatchers. import { + type ApiModel, casing, + type DateType, discriminatorCases, enumValues, flattenAllOf, - type DateType, -} from '../../authoring/index.js'; -import type { ApiModel, PropertyModel } from '../../intermediate-representation/model.js'; -import { exported, GoPrinter } from '../../printers/go.js'; + type PropertyModel, +} from '@redocly/client-generator'; +import { exported, GoPrinter } from '@redocly/client-generator/printers/go'; + import { naming } from './naming.js'; import { goType } from './types.js'; diff --git a/packages/client-generator/src/generators/go/naming.ts b/packages/client-generator/src/generators/go/naming.ts index b8d0ee79cc..521ef14a9d 100644 --- a/packages/client-generator/src/generators/go/naming.ts +++ b/packages/client-generator/src/generators/go/naming.ts @@ -1,9 +1,13 @@ // The `naming` stage: the shared printer/naming instance, the package clause, and // the collision-free operation identifiers every other stage builds on. -import { NotSupportedError, RESERVED_WORDS } from '../../authoring/index.js'; -import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; -import { exported, GoPrinter } from '../../printers/go.js'; +import { + type ApiModel, + NotSupportedError, + type OperationModel, + RESERVED_WORDS, +} from '@redocly/client-generator'; +import { exported, GoPrinter } from '@redocly/client-generator/printers/go'; // One escaping policy for every Go string literal this generator prints. export const naming = new GoPrinter(); diff --git a/packages/client-generator/src/generators/go/operations.ts b/packages/client-generator/src/generators/go/operations.ts index a6af87ae6a..1ff6491842 100644 --- a/packages/client-generator/src/generators/go/operations.ts +++ b/packages/client-generator/src/generators/go/operations.ts @@ -2,19 +2,18 @@ // argument and envelope-header planning it shares with the pagination wrappers. import { + type ApiModel, + type DateType, headerCoerceType, isMultipartBody, jsonSuccessSchema, + type OperationModel, + type ParamModel, sseResponse, uniqueIdentifiers, - type DateType, -} from '../../authoring/index.js'; -import type { - ApiModel, - OperationModel, - ParamModel, -} from '../../intermediate-representation/model.js'; -import { exported, type GoPrinter } from '../../printers/go.js'; +} from '@redocly/client-generator'; +import { exported, type GoPrinter } from '@redocly/client-generator/printers/go'; + import { GO, naming } from './naming.js'; import { goType } from './types.js'; diff --git a/packages/client-generator/src/generators/go/pagination.ts b/packages/client-generator/src/generators/go/pagination.ts index 1f6d1b97af..8975422108 100644 --- a/packages/client-generator/src/generators/go/pagination.ts +++ b/packages/client-generator/src/generators/go/pagination.ts @@ -1,8 +1,8 @@ // The `pagination` stage: the `Pages` / `Items` yield-func iterators. -import type { DateType } from '../../authoring/index.js'; -import type { OperationModel } from '../../intermediate-representation/model.js'; -import { exported, type GoPrinter } from '../../printers/go.js'; +import { type DateType, type OperationModel } from '@redocly/client-generator'; +import { exported, type GoPrinter } from '@redocly/client-generator/printers/go'; + import { naming } from './naming.js'; import { goQueryFormat, pathArguments } from './operations.js'; import { goType } from './types.js'; diff --git a/packages/client-generator/src/generators/go/types.ts b/packages/client-generator/src/generators/go/types.ts index 0e36cd43e8..62899eac65 100644 --- a/packages/client-generator/src/generators/go/types.ts +++ b/packages/client-generator/src/generators/go/types.ts @@ -1,8 +1,12 @@ // The `types` stage: the Go type annotation for a schema. -import { isNullable, unwrapNullable, type DateType } from '../../authoring/index.js'; -import type { SchemaModel } from '../../intermediate-representation/model.js'; -import { exported } from '../../printers/go.js'; +import { + type DateType, + isNullable, + type SchemaModel, + unwrapNullable, +} from '@redocly/client-generator'; +import { exported } from '@redocly/client-generator/printers/go'; /** The Go type for a schema; `required=false` optionals become pointers at the field site. */ export function goType(schema: SchemaModel, dateType: DateType = 'string'): string { diff --git a/packages/client-generator/src/generators/php/client.ts b/packages/client-generator/src/generators/php/client.ts index 6322770771..413ed0fc2f 100644 --- a/packages/client-generator/src/generators/php/client.ts +++ b/packages/client-generator/src/generators/php/client.ts @@ -1,9 +1,14 @@ // The `client` stage: the `Servers` helper class of one static method per // declared server. -import { identifierFor, serverUrlParts } from '../../authoring/index.js'; -import type { ApiModel, ServerModel } from '../../intermediate-representation/model.js'; -import type { PhpPrinter } from '../../printers/php.js'; +import { + type ApiModel, + identifierFor, + type ServerModel, + serverUrlParts, +} from '@redocly/client-generator'; +import type { PhpPrinter } from '@redocly/client-generator/printers/php'; + import { PHP, phpString, propertyName } from './naming.js'; /** The server URL as a PHP expression: literals concatenated with declared-variable args. */ diff --git a/packages/client-generator/src/generators/php/descriptor.ts b/packages/client-generator/src/generators/php/descriptor.ts index 8358dd9778..586d633d36 100644 --- a/packages/client-generator/src/generators/php/descriptor.ts +++ b/packages/client-generator/src/generators/php/descriptor.ts @@ -2,12 +2,14 @@ // OR-alternatives, the pagination spec, and envelope-header coerce specs. import { + type ApiModel, headerCoerceType, identifierFor, - securityRequirements, type NeutralPaginationRule, -} from '../../authoring/index.js'; -import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; + type OperationModel, + securityRequirements, +} from '@redocly/client-generator'; + import { PHP, phpString } from './naming.js'; /** Security literal for the operations table, denormalized from the model's schemes. */ diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index 5eae7f3d63..a9978be399 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -6,17 +6,20 @@ // embedded runtime. Exceptions are the error mode (`errorMode` does not apply). import { + type CodeSample, + type Generator, identifierFor, jsonSuccessSchema, + type NeutralPaginationRule, + type OperationModel, paginationItemSchema, renderReferencePage, + type SampleContext, sseResponse, - type NeutralPaginationRule, -} from '../../authoring/index.js'; -import { PHP_RUNTIME_SOURCE } from '../../emitters/php-runtime-sources.js'; -import type { OperationModel } from '../../intermediate-representation/model.js'; -import { PhpPrinter } from '../../printers/php.js'; -import type { CodeSample, Generator, SampleContext } from '../types.js'; +} from '@redocly/client-generator'; +import { PhpPrinter } from '@redocly/client-generator/printers/php'; +import { PHP_RUNTIME_SOURCE } from '@redocly/client-generator/runtime-sources'; + import { writeServers } from './client.js'; import { phpPaginationLiteral, phpSecurityLiteral } from './descriptor.js'; import { hydration, renderPhpModels } from './models.js'; diff --git a/packages/client-generator/src/generators/php/models.ts b/packages/client-generator/src/generators/php/models.ts index 9497607809..b43e2b45db 100644 --- a/packages/client-generator/src/generators/php/models.ts +++ b/packages/client-generator/src/generators/php/models.ts @@ -3,20 +3,19 @@ // dispatchers — plus the wire↔typed value expressions the methods reuse. import { + type ApiModel, + type DateType, deref, discriminatorCases, enumValues, flattenAllOf, + type PropertyModel, + type SchemaModel, uniqueIdentifiers, unwrapNullable, - type DateType, -} from '../../authoring/index.js'; -import type { - ApiModel, - PropertyModel, - SchemaModel, -} from '../../intermediate-representation/model.js'; -import { PhpPrinter } from '../../printers/php.js'; +} from '@redocly/client-generator'; +import { PhpPrinter } from '@redocly/client-generator/printers/php'; + import { className, PHP, phpString, propertyName } from './naming.js'; import { classify, isDateFormat, phpNullable, phpType } from './types.js'; diff --git a/packages/client-generator/src/generators/php/naming.ts b/packages/client-generator/src/generators/php/naming.ts index 3fe46ee17f..bd53b5c1c7 100644 --- a/packages/client-generator/src/generators/php/naming.ts +++ b/packages/client-generator/src/generators/php/naming.ts @@ -1,9 +1,14 @@ // The `naming` stage: the shared printer/naming instance, the string escaper, and // the collision-free class/property/method identifiers every other stage builds on. -import { identifierFor, RESERVED_WORDS, uniqueIdentifiers } from '../../authoring/index.js'; -import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; -import { PhpPrinter } from '../../printers/php.js'; +import { + type ApiModel, + identifierFor, + type OperationModel, + RESERVED_WORDS, + uniqueIdentifiers, +} from '@redocly/client-generator'; +import { PhpPrinter } from '@redocly/client-generator/printers/php'; export const PHP = RESERVED_WORDS.php; diff --git a/packages/client-generator/src/generators/php/operations.ts b/packages/client-generator/src/generators/php/operations.ts index 3eea6def6b..16941c1141 100644 --- a/packages/client-generator/src/generators/php/operations.ts +++ b/packages/client-generator/src/generators/php/operations.ts @@ -2,14 +2,16 @@ // argument planning and request prologue it shares with the pagination wrappers. import { + type ApiModel, + type DateType, isMultipartBody, jsonSuccessSchema, + type OperationModel, sseResponse, uniqueIdentifiers, - type DateType, -} from '../../authoring/index.js'; -import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; -import type { PhpPrinter } from '../../printers/php.js'; +} from '@redocly/client-generator'; +import type { PhpPrinter } from '@redocly/client-generator/printers/php'; + import { envelopeHeaderSpecs } from './descriptor.js'; import { hydration, serialization } from './models.js'; import { PHP, phpString } from './naming.js'; diff --git a/packages/client-generator/src/generators/php/pagination.ts b/packages/client-generator/src/generators/php/pagination.ts index d63e04edcb..20d049da94 100644 --- a/packages/client-generator/src/generators/php/pagination.ts +++ b/packages/client-generator/src/generators/php/pagination.ts @@ -1,9 +1,14 @@ // The `pagination` stage: the `Pages()` / `Items()` generator methods // over the runtime's iterPages. -import { jsonSuccessSchema, type DateType } from '../../authoring/index.js'; -import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; -import type { PhpPrinter } from '../../printers/php.js'; +import { + type ApiModel, + type DateType, + jsonSuccessSchema, + type OperationModel, +} from '@redocly/client-generator'; +import type { PhpPrinter } from '@redocly/client-generator/printers/php'; + import { phpString } from './naming.js'; import { methodArgs } from './operations.js'; import { phpType } from './types.js'; diff --git a/packages/client-generator/src/generators/php/types.ts b/packages/client-generator/src/generators/php/types.ts index 71748ed658..61dd620949 100644 --- a/packages/client-generator/src/generators/php/types.ts +++ b/packages/client-generator/src/generators/php/types.ts @@ -2,14 +2,16 @@ // union forms, and the element type PHP's own syntax erases. import { + type ApiModel, + type DateType, deref, enumValues, flattenAllOf, isNullable, + type SchemaModel, unwrapNullable, - type DateType, -} from '../../authoring/index.js'; -import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; +} from '@redocly/client-generator'; + import { className } from './naming.js'; /** What a named schema renders as: a class, a native enum, or nothing (alias). */ diff --git a/packages/client-generator/src/generators/python/client.ts b/packages/client-generator/src/generators/python/client.ts index be9d6d4b2a..9a82af2cdf 100644 --- a/packages/client-generator/src/generators/python/client.ts +++ b/packages/client-generator/src/generators/python/client.ts @@ -2,15 +2,17 @@ // classes that assemble the per-operation methods. import { + type ApiModel, + type DateType, identifierFor, jsonSuccessSchema, paginationItemSchema, + type ServerModel, serverUrlParts, sseResponse, - type DateType, -} from '../../authoring/index.js'; -import type { ApiModel, ServerModel } from '../../intermediate-representation/model.js'; -import type { PythonPrinter } from '../../printers/python.js'; +} from '@redocly/client-generator'; +import type { PythonPrinter } from '@redocly/client-generator/printers/python'; + import { fieldName, naming, operationIdents, PY } from './naming.js'; import { writeMethod } from './operations.js'; import { writePaginationWrappers } from './pagination.js'; diff --git a/packages/client-generator/src/generators/python/descriptor.ts b/packages/client-generator/src/generators/python/descriptor.ts index 209d310c73..e74783b872 100644 --- a/packages/client-generator/src/generators/python/descriptor.ts +++ b/packages/client-generator/src/generators/python/descriptor.ts @@ -2,11 +2,13 @@ // pagination specs, envelope-header coerce specs, and Python data literals. import { + type ApiModel, headerCoerceType, identifierFor, type NeutralPaginationRule, -} from '../../authoring/index.js'; -import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; + type OperationModel, +} from '@redocly/client-generator'; + import { naming, PY } from './naming.js'; /** JSON → Python literal (dicts/lists/strings/numbers/bools/None). */ diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index af38d40d7b..d5ec812740 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -4,11 +4,19 @@ // A guard test pins that this module never imports the TS emitter toolkit. // One file per pipeline stage (ADR-0020); this entry assembles them. -import { identifierFor, renderReferencePage, securityRequirements } from '../../authoring/index.js'; -import { PYTHON_RUNTIME_SOURCES } from '../../emitters/python-runtime-sources.js'; -import type { OperationModel } from '../../intermediate-representation/model.js'; -import { PythonPrinter } from '../../printers/python.js'; -import type { CodeSample, Generator, GeneratorOptionsSchema, SampleContext } from '../types.js'; +import { + type CodeSample, + type Generator, + type GeneratorOptionsSchema, + identifierFor, + type OperationModel, + renderReferencePage, + type SampleContext, + securityRequirements, +} from '@redocly/client-generator'; +import { PythonPrinter } from '@redocly/client-generator/printers/python'; +import { PYTHON_RUNTIME_SOURCES } from '@redocly/client-generator/runtime-sources'; + import { writeClientClass, writePythonServers } from './client.js'; import { paginationSpec, pythonLiteral } from './descriptor.js'; import { diff --git a/packages/client-generator/src/generators/python/models.ts b/packages/client-generator/src/generators/python/models.ts index f2cdf022dd..8c9ba83aa1 100644 --- a/packages/client-generator/src/generators/python/models.ts +++ b/packages/client-generator/src/generators/python/models.ts @@ -2,13 +2,15 @@ // (allOf flattened), union aliases, and the decoder's discriminator registrations. import { + type ApiModel, + type DateType, discriminatorCases, enumValues, flattenAllOf, - type DateType, -} from '../../authoring/index.js'; -import type { ApiModel, PropertyModel } from '../../intermediate-representation/model.js'; -import { PythonPrinter } from '../../printers/python.js'; + type PropertyModel, +} from '@redocly/client-generator'; +import { PythonPrinter } from '@redocly/client-generator/printers/python'; + import { className, fieldName, naming } from './naming.js'; import { pythonType } from './types.js'; diff --git a/packages/client-generator/src/generators/python/naming.ts b/packages/client-generator/src/generators/python/naming.ts index 16cbd6db7a..14644b3e27 100644 --- a/packages/client-generator/src/generators/python/naming.ts +++ b/packages/client-generator/src/generators/python/naming.ts @@ -1,9 +1,13 @@ // The `naming` stage: the shared printer/naming instance and the collision-free // identifier derivations every other stage builds on. -import { RESERVED_WORDS, uniqueIdentifiers } from '../../authoring/index.js'; -import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; -import { PythonPrinter } from '../../printers/python.js'; +import { + type ApiModel, + type OperationModel, + RESERVED_WORDS, + uniqueIdentifiers, +} from '@redocly/client-generator'; +import { PythonPrinter } from '@redocly/client-generator/printers/python'; export const PY = RESERVED_WORDS.python; diff --git a/packages/client-generator/src/generators/python/operations.ts b/packages/client-generator/src/generators/python/operations.ts index 893c2f94ef..9e2a697e34 100644 --- a/packages/client-generator/src/generators/python/operations.ts +++ b/packages/client-generator/src/generators/python/operations.ts @@ -2,14 +2,16 @@ // with the optional `_with_headers` envelope variant. import { + type ApiModel, + type DateType, isMultipartBody, jsonSuccessSchema, + type OperationModel, sseResponse, uniqueIdentifiers, - type DateType, -} from '../../authoring/index.js'; -import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; -import type { PythonPrinter } from '../../printers/python.js'; +} from '@redocly/client-generator'; +import type { PythonPrinter } from '@redocly/client-generator/printers/python'; + import { envelopeHeaderSpecs } from './descriptor.js'; import { METHOD_ARG_SLOTS, naming, PY } from './naming.js'; import { pythonType } from './types.js'; diff --git a/packages/client-generator/src/generators/python/pagination.ts b/packages/client-generator/src/generators/python/pagination.ts index 6a9904161f..4c0602fe66 100644 --- a/packages/client-generator/src/generators/python/pagination.ts +++ b/packages/client-generator/src/generators/python/pagination.ts @@ -1,9 +1,14 @@ // The `pagination` stage: `_pages` / `_items` iterator methods for // paginated operations, sync and async. -import { jsonSuccessSchema, uniqueIdentifiers, type DateType } from '../../authoring/index.js'; -import type { OperationModel } from '../../intermediate-representation/model.js'; -import type { PythonPrinter } from '../../printers/python.js'; +import { + type DateType, + jsonSuccessSchema, + type OperationModel, + uniqueIdentifiers, +} from '@redocly/client-generator'; +import type { PythonPrinter } from '@redocly/client-generator/printers/python'; + import { METHOD_ARG_SLOTS, naming, PY } from './naming.js'; import { pythonType } from './types.js'; diff --git a/packages/client-generator/src/generators/python/types.ts b/packages/client-generator/src/generators/python/types.ts index 9e25c65cdb..afc50e8929 100644 --- a/packages/client-generator/src/generators/python/types.ts +++ b/packages/client-generator/src/generators/python/types.ts @@ -1,7 +1,12 @@ // The `types` stage: schema → Python type annotation. -import { isNullable, unwrapNullable, type DateType } from '../../authoring/index.js'; -import type { SchemaModel } from '../../intermediate-representation/model.js'; +import { + type DateType, + isNullable, + type SchemaModel, + unwrapNullable, +} from '@redocly/client-generator'; + import { className, naming } from './naming.js'; /** The Python type annotation for a schema (anonymous complex shapes collapse to Any-ish). */ diff --git a/packages/client-generator/src/plugin.ts b/packages/client-generator/src/plugin.ts index ddb4a42117..66a8090bf9 100644 --- a/packages/client-generator/src/plugin.ts +++ b/packages/client-generator/src/plugin.ts @@ -42,14 +42,21 @@ export function defineGenerator(generator: CustomGenerator): CustomGenerator { // --- The authoring contract + the data a generator receives ----------------------------------- export type { + ArgsStyle, + CodeSample, CustomGenerator, + DateType, + EmitOptions, + ErrorMode, GeneratedFile, Generator, GeneratorInput, GeneratorName, + GeneratorOptionsSchema, + OutputAnchor, OutputMode, + SampleContext, } from './generators/types.js'; -export type { ArgsStyle, DateType, ErrorMode } from './generators/types.js'; // --- The intermediate representation (the `model` a generator walks) --------------------------- export type { @@ -63,7 +70,9 @@ export type { ScalarKind, SchemaMetadata, SchemaModel, + ServerModel, ServiceModel, + SseModel, } from './intermediate-representation/model.js'; // The TypeScript-emitting renderers (`tsType`, `operationSignature`, …) are exported from diff --git a/tsconfig.json b/tsconfig.json index de6decf878..8ed27b7a41 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,15 @@ "forceConsistentCasingInFileNames": true, "allowJs": false, "lib": ["ESNext", "DOM", "dom.iterable"], - "paths": { "*": ["./packages/*"] }, + "paths": { + "*": ["./packages/*"], + "@redocly/client-generator": ["./packages/client-generator/src/index.ts"], + "@redocly/client-generator/printers/*": ["./packages/client-generator/src/printers/*.ts"], + "@redocly/client-generator/contracts/*": ["./packages/client-generator/src/contracts/*.ts"], + "@redocly/client-generator/runtime-sources": [ + "./packages/client-generator/src/runtime-sources.ts" + ] + }, "skipLibCheck": true, "moduleResolution": "nodenext", "esModuleInterop": true diff --git a/vitest.config.ts b/vitest.config.ts index efd0491433..6c73e3ff99 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -60,6 +60,28 @@ const configExtension: { [key: string]: ViteUserConfig } = { export default mergeConfig( defineConfig({ + // Generator-folder sources import their own package by name (the same specifier an + // ejected copy uses); resolve those to src so tests exercise the working tree, not lib. + resolve: { + alias: [ + { + find: /^@redocly\/client-generator\/printers\/([a-z]+)$/, + replacement: `${import.meta.dirname}/packages/client-generator/src/printers/$1.ts`, + }, + { + find: /^@redocly\/client-generator\/contracts\/([a-z]+)$/, + replacement: `${import.meta.dirname}/packages/client-generator/src/contracts/$1.ts`, + }, + { + find: '@redocly/client-generator/runtime-sources', + replacement: `${import.meta.dirname}/packages/client-generator/src/runtime-sources.ts`, + }, + { + find: /^@redocly\/client-generator$/, + replacement: `${import.meta.dirname}/packages/client-generator/src/index.ts`, + }, + ], + }, test: { globals: true, restoreMocks: true, From 11b193ad3138a231a89a866592d8acfbf23c726d Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 22:47:06 +0300 Subject: [PATCH 27/35] feat: eject the language generators as their TypeScript source folders with per-file update merges and a Node type-stripping floor --- docs/@v2/commands/eject-generator.md | 33 +- .../@v2/guides/customize-client-generation.md | 2 +- .../__tests__/eject-generator.test.ts | 30 +- packages/cli/src/commands/eject-generator.ts | 182 +++++- .../eject-assets/skills/go-generator/SKILL.md | 8 +- .../skills/php-generator/SKILL.md | 8 +- .../skills/python-generator/SKILL.md | 8 +- .../scripts/ejected-skill.d.mts | 6 +- .../scripts/ejected-skill.mjs | 16 +- .../scripts/generate-eject-assets.mjs | 69 ++- .../__tests__/generator-skills.test.ts | 18 +- .../__tests__/language-dogfooding.test.ts | 2 +- .../src/generators/go/client.ts | 2 +- .../src/generators/go/descriptor.ts | 2 +- .../src/generators/go/index.ts | 18 +- .../src/generators/go/models.ts | 4 +- .../src/generators/go/operations.ts | 4 +- .../src/generators/go/pagination.ts | 6 +- .../src/generators/php/client.ts | 2 +- .../src/generators/php/descriptor.ts | 2 +- .../src/generators/php/index.ts | 18 +- .../src/generators/php/models.ts | 4 +- .../src/generators/php/operations.ts | 8 +- .../src/generators/php/pagination.ts | 6 +- .../src/generators/php/types.ts | 2 +- .../src/generators/python/client.ts | 8 +- .../src/generators/python/descriptor.ts | 2 +- .../src/generators/python/index.ts | 12 +- .../src/generators/python/models.ts | 4 +- .../src/generators/python/operations.ts | 6 +- .../src/generators/python/pagination.ts | 4 +- .../src/generators/python/types.ts | 2 +- .../src/generators/resolve.ts | 7 + tests/e2e/generate-client/eject.test.ts | 44 +- .../.claude/skills/php-generator/SKILL.md | 8 +- .../examples/ejected-generator/README.md | 11 +- .../ejected-generator/generators/php.mjs | 578 ------------------ .../generators/php/client.ts | 63 ++ .../generators/php/descriptor.ts | 58 ++ .../ejected-generator/generators/php/index.ts | 220 +++++++ .../generators/php/models.ts | 275 +++++++++ .../generators/php/naming.ts | 51 ++ .../generators/php/operations.ts | 232 +++++++ .../generators/php/pagination.ts | 133 ++++ .../ejected-generator/generators/php/types.ts | 148 +++++ .../examples/ejected-generator/redocly.yaml | 2 +- tsconfig.json | 1 + 47 files changed, 1565 insertions(+), 764 deletions(-) delete mode 100644 tests/e2e/generate-client/examples/ejected-generator/generators/php.mjs create mode 100644 tests/e2e/generate-client/examples/ejected-generator/generators/php/client.ts create mode 100644 tests/e2e/generate-client/examples/ejected-generator/generators/php/descriptor.ts create mode 100644 tests/e2e/generate-client/examples/ejected-generator/generators/php/index.ts create mode 100644 tests/e2e/generate-client/examples/ejected-generator/generators/php/models.ts create mode 100644 tests/e2e/generate-client/examples/ejected-generator/generators/php/naming.ts create mode 100644 tests/e2e/generate-client/examples/ejected-generator/generators/php/operations.ts create mode 100644 tests/e2e/generate-client/examples/ejected-generator/generators/php/pagination.ts create mode 100644 tests/e2e/generate-client/examples/ejected-generator/generators/php/types.ts diff --git a/docs/@v2/commands/eject-generator.md b/docs/@v2/commands/eject-generator.md index 4596419700..b1d01a9533 100644 --- a/docs/@v2/commands/eject-generator.md +++ b/docs/@v2/commands/eject-generator.md @@ -2,7 +2,7 @@ ## Introduction -The `eject-generator` command copies a built-in client generator into your repository as an editable file. +The `eject-generator` command copies a built-in client generator into your repository as editable source. You own the ejected generator and can customize it. The generated client stays generated and reproducible. Do not edit it manually. @@ -29,22 +29,21 @@ redocly eject-generator php --force | ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | | generator | string | The built-in generator to eject. | | `--config` | string | The path to the config file. | -| `--dir` | string | The directory that receives the ejected files. Default: `./generators`. | +| `--dir` | string | The directory that receives the ejected copy. Default: `./generators`. | | `--update` | boolean | Do a three-way merge of the current built-in version into your customized copy. The command marks conflicts with standard markers. | -| `--force` | boolean | Overwrite an existing ejected file and discard the local edits. | +| `--force` | boolean | Overwrite an existing ejected copy and discard the local edits. | ## How it works -The eject operation writes two files: +The eject operation writes the generator and its design: -- `/.mjs` is the generator itself, as a plain ESM file that you own. - The file contains everything that it needs to run standalone. - A language generator (`python`, `go`, `php`) is one self-contained file. - You get its source exactly as it was written. - A TypeScript generator is a thin entry point that uses shared emitters, so you get it bundled together with those emitters. +- A language generator (`python`, `go`, `php`) ejects as `//` — its TypeScript source folder, exactly as it was written. + Each stage of the generator is one file (`naming.ts`, `types.ts`, `models.ts`, `descriptor.ts`, `operations.ts`, `pagination.ts`, `client.ts`), and `index.ts` is the entry. + Running a TypeScript generator uses Node's own type stripping, which requires Node 22.18, 23.6, or newer. +- A TypeScript-family generator ejects as one plain ESM file, `/.mjs`, bundled together with the shared modules that it uses. 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`. + In both cases, the generator 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. @@ -65,17 +64,17 @@ The command keeps everything that you add outside the markers in that file. The eject command also configures your project. It adds `@redocly/client-generator` to your `devDependencies` if the package is not there. -It also points your config at the ejected file: in `client.generators`, the path to your copy replaces the built-in name. +It also points your config at the ejected copy: in `client.generators`, the path to your copy replaces the built-in name. If the config has no `client.generators` list yet, the command adds one. ```yaml client: generators: - - ./generators/python.mjs + - ./generators/python/index.ts ``` If you leave the ejected generator unmodified, its output is byte-identical to the output of the built-in generator. -To roll back, delete the file and the config line. +To roll back, delete the ejected copy and the config line. ## Run the ejected generator @@ -85,21 +84,21 @@ Generation is the same command as before the eject, because the config now point redocly generate-client openapi.yaml --output src/client.ts ``` -If you did not wire the config, name the file with `--generator`: +If you did not wire the config, name your copy with `--generator`: ```sh -redocly generate-client openapi.yaml --output src/client.ts --generator ./generators/python.mjs +redocly generate-client openapi.yaml --output src/client.ts --generator ./generators/python/index.ts ``` The command reports a generator that takes over a built-in name, so you can see that your copy is the one that runs. -Edit the file and run the command again to see the change. +Edit your copy and run the command again to see the change. The eject command prints these instructions as well. ## Update an ejected generator The `redocly eject-generator --update` command merges a newer version into your copy. That version is the one shipped by your installed `@redocly/client-generator` package. -The three-way merge uses the version recorded in the header of the ejected file as the common ancestor. +The three-way merge uses the version recorded in the header of each ejected file as the common ancestor, and a folder generator merges file by file. Because of this, you do not have to commit extra files, and there is no snapshot to keep in sync. The command merges the two skills in the same way, so an update keeps the design notes that you added to them. diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index c21aa32aaa..823b622333 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -77,7 +77,7 @@ See the [`baked-setup` example](https://github.com/Redocly/redocly-cli/tree/main The quickest method to get a customized generator is [`redocly eject-generator `](../commands/eject-generator.md). -The command copies any built-in generator into `./generators/` as an editable file that you own. +The command copies any built-in generator into `./generators/` as editable source that you own — a language generator as its TypeScript folder, a TypeScript-family generator as one `.mjs` file. 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. diff --git a/packages/cli/src/commands/__tests__/eject-generator.test.ts b/packages/cli/src/commands/__tests__/eject-generator.test.ts index e2dcdeb1ab..f3162ebba9 100644 --- a/packages/cli/src/commands/__tests__/eject-generator.test.ts +++ b/packages/cli/src/commands/__tests__/eject-generator.test.ts @@ -30,7 +30,7 @@ describe('wireConfig', () => { const configPath = join(dir, 'redocly.yaml'); writeFileSync(configPath, source, 'utf-8'); try { - expect(wireConfig(configPath, 'php', './generators/php.mjs')).toBe(true); + expect(wireConfig(configPath, 'php', './generators/php/index.ts')).toBe(true); return readFileSync(configPath, 'utf-8'); } finally { rmSync(dir, { recursive: true, force: true }); @@ -48,11 +48,11 @@ describe('wireConfig', () => { ).toBe(outdent` client: generators: - - ./generators/php.mjs + - ./generators/php/index.ts - typescript `); expect(wire('client:\n generators: [php, typescript]\n')).toBe( - 'client:\n generators: [./generators/php.mjs, typescript]\n' + 'client:\n generators: [./generators/php/index.ts, typescript]\n' ); }); @@ -67,7 +67,7 @@ describe('wireConfig', () => { client: generators: - typescript - - ./generators/php.mjs + - ./generators/php/index.ts `); }); @@ -83,7 +83,7 @@ describe('wireConfig', () => { ).toBe(outdent` client: generators: - - ./generators/php.mjs + - ./generators/php/index.ts runtime: package apis: cafe: @@ -95,23 +95,23 @@ describe('wireConfig', () => { // A mention outside the list (a comment, a longer path) is not wiring. expect( wire(outdent` - # was: ./generators/php.mjs + # was: ./generators/php/index.ts client: generators: - typescript `) ).toBe(outdent` - # was: ./generators/php.mjs + # was: ./generators/php/index.ts client: generators: - typescript - - ./generators/php.mjs + - ./generators/php/index.ts `); // A real list entry is — the file stays unchanged. const wired = outdent` client: generators: - - ./generators/php.mjs + - ./generators/php/index.ts `; expect(wire(wired)).toBe(wired); }); @@ -129,7 +129,7 @@ describe('wireConfig', () => { client: generators: # our copies: - - ./generators/php.mjs # ours + - ./generators/php/index.ts # ours - typescript `); // An already-wired entry behind a comment line is found, not duplicated. @@ -138,7 +138,7 @@ describe('wireConfig', () => { generators: - typescript # ejected: - - ./generators/php.mjs + - ./generators/php/index.ts `; expect(wire(wired)).toBe(wired); }); @@ -160,7 +160,7 @@ describe('wireConfig', () => { 'utf-8' ); try { - expect(wireConfig(configPath, 'php', './generators/php.mjs')).toBe(false); + expect(wireConfig(configPath, 'php', './generators/php/index.ts')).toBe(false); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -184,7 +184,7 @@ describe('wireConfig', () => { clientOutput: ./src/client.ts client: generators: - - ./generators/php.mjs + - ./generators/php/index.ts ` + '\n' ); }); @@ -276,13 +276,13 @@ describe('packedAssets', () => { // A directory stands in for the version spec `--update` passes: same pack, same // extraction, no registry needed to prove the mechanism. const members = [ - 'package/eject-assets/generators/php.mjs', + 'package/eject-assets/generators/php/index.ts', 'package/eject-assets/skills/php-generator/SKILL.md', 'package/eject-assets/skills/not-a-member/SKILL.md', ]; const assets = packedAssets(clientGeneratorDir, members); expect(assets.get(members[0])).toBe( - readFileSync(join(clientGeneratorDir, 'eject-assets/generators/php.mjs'), 'utf-8') + readFileSync(join(clientGeneratorDir, 'eject-assets/generators/php/index.ts'), 'utf-8') ); expect(assets.get(members[1])).toBe( readFileSync(join(clientGeneratorDir, 'eject-assets/skills/php-generator/SKILL.md'), 'utf-8') diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index f2fa3d9b8d..13d265728c 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -8,6 +8,7 @@ import { readFileSync, realpathSync, rmSync, + statSync, writeFileSync, } from 'node:fs'; import { createRequire } from 'node:module'; @@ -221,8 +222,23 @@ export function packedAssets(spec: string, members: string[]): Map `package/eject-assets/generators/${name}.mjs`; +const folderMember = (name: string, file: string) => + `package/eject-assets/generators/${name}/${file}`; const skillMember = (skill: string) => `package/eject-assets/skills/${skill}/SKILL.md`; +/** + * How a generator ships: the language generators are FOLDERS of TypeScript stage files + * (ejected verbatim, run under Node's type stripping); the TypeScript-family generators + * are one bundled `.mjs` each. + */ +function assetFolderFiles(assetsDir: string, name: string): string[] | undefined { + const folder = join(assetsDir, 'generators', name); + if (!existsSync(folder) || !statSync(folder).isDirectory()) return undefined; + return readdirSync(folder) + .filter((file) => file.endsWith('.ts')) + .sort(); +} + /** * Refresh one skill during `--update`. The skill tells its owner to edit it first, so it * gets the same three-way merge as the generator: ours is the user's copy, the base is @@ -254,7 +270,9 @@ function updateSkill(skill: string, assetsDir: string, baseSkill: string | undef /** The built-in generators already ejected into `dir`, so the pointer lists every one of them. */ function ejectedIn(dir: string): string[] { - return [...EJECTABLE].filter((name) => existsSync(join(dir, `${name}.mjs`))); + return [...EJECTABLE].filter( + (name) => existsSync(join(dir, `${name}.mjs`)) || existsSync(join(dir, name, 'index.ts')) + ); } /** @@ -406,6 +424,111 @@ export function wireConfig(configPath: string | undefined, name: string, entry: return true; } +/** + * The `--update` flow for a folder generator: three-way-merge each stage file the new + * version ships (a file the user lacks is written new; a base that cannot be fetched + * leaves the user's copy and drops the update beside it as `.new`), merge the two + * skills the same way, and report the total conflict count. Files the user added and the + * new version does not ship are left alone — they are the user's. + */ +function updateEjectedFolder({ + name, + files, + toolkitVersion, + assetsDir, + dir, + targetDir, +}: { + name: string; + files: string[]; + toolkitVersion: string; + assetsDir: string; + dir: string; + targetDir: string; +}): void { + const entry = join(targetDir, 'index.ts'); + const printedEntry = relative(process.cwd(), entry) || entry; + if (!existsSync(entry)) { + ejectGeneratorTelemetry.eject_generator_outcome = 'missing-target'; + const legacy = join(dir, `${name}.mjs`); + throw new HandledError( + existsSync(legacy) + ? `\n❌ ${relative(process.cwd(), legacy)} is a single-file eject from an older version; this version ejects a folder. Eject fresh: redocly eject-generator ${name} --force\n` + : `\n❌ Nothing to update: ${printedEntry} does not exist. Eject first.\n` + ); + } + const from = recordedVersion(readFileSync(entry, 'utf-8')); + if (from !== undefined && semver.valid(from) !== null) { + ejectGeneratorTelemetry.eject_generator_from_version = from; + } + ejectGeneratorTelemetry.eject_generator_to_version = toolkitVersion; + // One pack fetches every merge base: each stage file plus both skills. + const packed = + from === toolkitVersion || from === undefined + ? new Map() + : packedAssets(`${TOOLKIT_PACKAGE}@${from}`, [ + ...files.map((file) => folderMember(name, file)), + skillMember('client-generators'), + skillMember(`${name}-generator`), + ]); + if (from === undefined) { + ejectGeneratorTelemetry.eject_generator_outcome = 'missing-base'; + throw new HandledError( + `\n❌ Could not read the version ${printedEntry} was ejected from (not recorded in its header), so there is no merge base.\n` + + ` Eject to a temporary directory and diff by hand, or re-eject with --force.\n` + ); + } + let conflicts = 0; + for (const file of files) { + const updated = readFileSync(join(assetsDir, 'generators', name, file), 'utf-8'); + const target = join(targetDir, file); + if (!existsSync(target)) { + writeFileSync(target, updated, 'utf-8'); + continue; + } + const customized = readFileSync(target, 'utf-8'); + if (customized === updated) continue; + const base = from === toolkitVersion ? updated : packed.get(folderMember(name, file)); + if (base === undefined) { + writeFileSync(`${target}.new`, updated, 'utf-8'); + logger.warn( + `${relative(process.cwd(), target)} has no merge base in ${TOOLKIT_PACKAGE}@${from} — the new file is beside it as ${file}.new.\n` + ); + continue; + } + const merged = threeWayMerge(customized, base, updated); + writeFileSync(target, merged.merged, 'utf-8'); + conflicts += merged.conflicts; + } + const skillBase = (skill: string): string | undefined => + from === toolkitVersion + ? readFileSync(join(assetsDir, 'skills', skill, 'SKILL.md'), 'utf-8') + : packed.get(skillMember(skill)); + const skillConflicts = + updateSkill('client-generators', assetsDir, skillBase('client-generators')) + + updateSkill(`${name}-generator`, assetsDir, skillBase(`${name}-generator`)); + dropPointer(dir, ejectedIn(dir)); + const dependency = wireDependency({ [TOOLKIT_PACKAGE]: toolkitVersion }, true); + if (dependency === 'updated' || dependency === 'added') { + logger.info( + `Set ${TOOLKIT_PACKAGE} to ^${toolkitVersion} in package.json — run your installer.\n` + ); + } + const totalConflicts = conflicts + skillConflicts; + ejectGeneratorTelemetry.eject_generator_outcome = totalConflicts > 0 ? 'conflicts' : 'success'; + const printedDir = relative(process.cwd(), targetDir) || targetDir; + if (totalConflicts > 0) { + ejectGeneratorTelemetry.eject_generator_conflicts = totalConflicts; + logger.warn( + `Updated ${printedDir} with ${totalConflicts} conflict(s)${ + skillConflicts > 0 ? ' (some in .claude/skills)' : '' + } — resolve the <<<<<<< markers, then regenerate.\n` + ); + } else { + logger.info(`Updated ${printedDir} cleanly.\n`); + } +} + /** * The `--update` flow: three-way-merge the newer built-in version into the user's copy, * merging the two skills the same way, and report the conflict count. @@ -535,16 +658,37 @@ export const handleEjectGenerator = async ({ } const assetsDir = ejectAssetsDir(); - const asset = readFileSync(join(assetsDir, 'generators', `${name}.mjs`), 'utf-8'); + const folderFiles = assetFolderFiles(assetsDir, name); // The ejected file records and imports the toolkit's version; the CLI versions // independently of it. const { GENERATOR_VERSION: toolkitVersion } = await import('@redocly/client-generator'); const dir = resolve(argv.dir ?? './generators'); - const target = join(dir, `${name}.mjs`); + // A folder generator's existence, config entry, and provenance all key on its index.ts. + const target = folderFiles === undefined ? join(dir, `${name}.mjs`) : join(dir, name, 'index.ts'); const printedTarget = relative(process.cwd(), target) || target; if (argv.update) { - updateEjectedGenerator({ name, asset, toolkitVersion, assetsDir, dir, target, printedTarget }); + if (folderFiles === undefined) { + const asset = readFileSync(join(assetsDir, 'generators', `${name}.mjs`), 'utf-8'); + updateEjectedGenerator({ + name, + asset, + toolkitVersion, + assetsDir, + dir, + target, + printedTarget, + }); + } else { + updateEjectedFolder({ + name, + files: folderFiles, + toolkitVersion, + assetsDir, + dir, + targetDir: join(dir, name), + }); + } return; } @@ -554,8 +698,18 @@ export const handleEjectGenerator = async ({ `\n❌ ${printedTarget} already exists. Use --update to merge the newer version in, or --force to overwrite.\n` ); } - mkdirSync(dir, { recursive: true }); - writeFileSync(target, asset, 'utf-8'); + mkdirSync(dirname(target), { recursive: true }); + const copied = + folderFiles === undefined + ? [readFileSync(join(assetsDir, 'generators', `${name}.mjs`), 'utf-8')] + : folderFiles.map((file) => readFileSync(join(assetsDir, 'generators', name, file), 'utf-8')); + if (folderFiles === undefined) { + writeFileSync(target, copied[0], 'utf-8'); + } else { + folderFiles.forEach((file, index) => { + writeFileSync(join(dir, name, file), copied[index], 'utf-8'); + }); + } const authoringSkill = dropSkill('client-generators', assetsDir); const designSkill = dropSkill(`${name}-generator`, assetsDir); dropPointer(dir, ejectedIn(dir)); @@ -569,10 +723,15 @@ export const handleEjectGenerator = async ({ .join('/')}`; const dependency = wireDependency({ [TOOLKIT_PACKAGE]: toolkitVersion }); // A bundled TypeScript generator also imports from core; without hoisting it must be explicit. - const needsCore = asset.includes(`from "${CORE_PACKAGE}"`); + const needsCore = copied.some( + (source) => + source.includes(`from "${CORE_PACKAGE}"`) || source.includes(`from '${CORE_PACKAGE}'`) + ); const wired = wireConfig(config.configPath, name, configEntry); + const printedLocation = + folderFiles === undefined ? printedTarget : relative(process.cwd(), join(dir, name)) + '/'; logger.info( - `Ejected the "${name}" generator to ${printedTarget}.\n` + + `Ejected the "${name}" generator to ${printedLocation}.\n` + (dependency === 'added' ? `Added ${TOOLKIT_PACKAGE} to devDependencies (the ejected file imports its toolkit) — run your installer.\n` : dependency === 'no-package-json' @@ -583,14 +742,17 @@ export const handleEjectGenerator = async ({ : '') + (wired ? `Added it to client.generators in ${relative(process.cwd(), config.configPath!)} — the path to your copy replaces the built-in name.\n` - : `Point your config at the file — the path to your copy replaces the built-in name:\n\n` + + : `Point your config at the ${folderFiles === undefined ? 'file' : 'entry file'} — the path to your copy replaces the built-in name:\n\n` + ` client:\n generators:\n - ${configEntry}\n\n`) + + (folderFiles === undefined + ? '' + : `Running TypeScript generators uses Node's type stripping — Node 22.18 or 23.6 and newer.\n`) + `Your agent's skills: ${designSkill} (this generator's design) and ${authoringSkill} (the toolkit).\n` + // The next command, spelled out: a wired config still needs an output, and an unwired // copy is reached with `--generator`. Either way the reader can run it without // leaving the terminal to look it up. `\nRun it: redocly generate-client --output ${wired ? '' : ` --generator ${configEntry}`}\n` + - `Edit ${printedTarget} and run that again to see your change.\n` + + `Edit ${printedLocation} and run that again to see your change.\n` + `Reference: ${DOCS_URL}\n` ); // Last, so wiring the dependency or the config entry failing is not reported as success. diff --git a/packages/client-generator/eject-assets/skills/go-generator/SKILL.md b/packages/client-generator/eject-assets/skills/go-generator/SKILL.md index d461feb9e9..175fe3ca5e 100644 --- a/packages/client-generator/eject-assets/skills/go-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/go-generator/SKILL.md @@ -1,13 +1,13 @@ --- name: go-generator -description: Design of the ejected Redocly `go` client generator. Read it, and update it, before changing generators/go.mjs. +description: Design of the ejected Redocly `go` client generator. Read it, and update it, before changing generators/go/. --- # The `go` generator — its skill -This file is the DESIGN of your ejected `go` generator (`generators/go.mjs`): +This file is the DESIGN of your ejected `go` generator (`generators/go/`): **to change the generator, edit this skill first, then make the code match it** — a diff -to `generators/go.mjs` that has no covering sentence here is incomplete. +to `generators/go/` that has no covering sentence here is incomplete. ## What it emits @@ -87,7 +87,7 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies. ## The modify loop 1. Edit this skill: state the new behavior or decision. -2. Make `generators/go.mjs` match it. +2. Make `generators/go/` match it. 3. Run `redocly generate-client` and inspect the `git diff` of the generated output — generated files are never hand-edited. diff --git a/packages/client-generator/eject-assets/skills/php-generator/SKILL.md b/packages/client-generator/eject-assets/skills/php-generator/SKILL.md index acfed7323e..1508aba0ed 100644 --- a/packages/client-generator/eject-assets/skills/php-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/php-generator/SKILL.md @@ -1,13 +1,13 @@ --- name: php-generator -description: Design of the ejected Redocly `php` client generator. Read it, and update it, before changing generators/php.mjs. +description: Design of the ejected Redocly `php` client generator. Read it, and update it, before changing generators/php/. --- # The `php` generator — its skill -This file is the DESIGN of your ejected `php` generator (`generators/php.mjs`): +This file is the DESIGN of your ejected `php` generator (`generators/php/`): **to change the generator, edit this skill first, then make the code match it** — a diff -to `generators/php.mjs` that has no covering sentence here is incomplete. +to `generators/php/` that has no covering sentence here is incomplete. ## What it emits @@ -103,7 +103,7 @@ $idempotencyKey` on mutating methods. ## The modify loop 1. Edit this skill: state the new behavior or decision. -2. Make `generators/php.mjs` match it. +2. Make `generators/php/` match it. 3. Run `redocly generate-client` and inspect the `git diff` of the generated output — generated files are never hand-edited. diff --git a/packages/client-generator/eject-assets/skills/python-generator/SKILL.md b/packages/client-generator/eject-assets/skills/python-generator/SKILL.md index 29204fe6d9..5173a6b44f 100644 --- a/packages/client-generator/eject-assets/skills/python-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/python-generator/SKILL.md @@ -1,13 +1,13 @@ --- name: python-generator -description: Design of the ejected Redocly `python` client generator. Read it, and update it, before changing generators/python.mjs. +description: Design of the ejected Redocly `python` client generator. Read it, and update it, before changing generators/python/. --- # The `python` generator — its skill -This file is the DESIGN of your ejected `python` generator (`generators/python.mjs`): +This file is the DESIGN of your ejected `python` generator (`generators/python/`): **to change the generator, edit this skill first, then make the code match it** — a diff -to `generators/python.mjs` that has no covering sentence here is incomplete. +to `generators/python/` that has no covering sentence here is incomplete. ## What it emits @@ -101,7 +101,7 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a ## The modify loop 1. Edit this skill: state the new behavior or decision. -2. Make `generators/python.mjs` match it. +2. Make `generators/python/` match it. 3. Run `redocly generate-client` and inspect the `git diff` of the generated output — generated files are never hand-edited. diff --git a/packages/client-generator/scripts/ejected-skill.d.mts b/packages/client-generator/scripts/ejected-skill.d.mts index c8f75d743a..0d318d1427 100644 --- a/packages/client-generator/scripts/ejected-skill.d.mts +++ b/packages/client-generator/scripts/ejected-skill.d.mts @@ -1 +1,5 @@ -export function ejectedSkill(source: string, name: string): string; +export function ejectedSkill( + source: string, + name: string, + options?: { folder?: boolean } +): string; diff --git a/packages/client-generator/scripts/ejected-skill.mjs b/packages/client-generator/scripts/ejected-skill.mjs index 070e94842a..21e22aa5d8 100644 --- a/packages/client-generator/scripts/ejected-skill.mjs +++ b/packages/client-generator/scripts/ejected-skill.mjs @@ -2,15 +2,17 @@ // into the user's `.claude/skills/`. The source skill speaks to development inside this repo — its intro and modify // loop reference index.ts, the prepare script, and our vitest suites, none of which // exist in a user's repo. The ejected copy keeps the design sections verbatim but -// rewrites those two parts for the user's world: their file is generators/.mjs -// and their loop is edit → regenerate → diff. The design bullets in between ship +// rewrites those two parts for the user's world: their copy is generators// (a +// language generator's source folder) or generators/.mjs (a bundled TypeScript +// generator), 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) { +export function ejectedSkill(source, name, { folder = false } = {}) { + const copy = folder ? `generators/${name}/` : `generators/${name}.mjs`; const frontmatter = [ '---', `name: ${name}-generator`, - `description: Design of the ejected Redocly \`${name}\` client generator. Read it, and update it, before changing generators/${name}.mjs.`, + `description: Design of the ejected Redocly \`${name}\` client generator. Read it, and update it, before changing ${copy}.`, '---', '', ].join('\n'); @@ -21,15 +23,15 @@ export function ejectedSkill(source, name) { throw new Error(`The ${name} skill lost its title/intro/modify-loop structure.`); } const intro = [ - `This file is the DESIGN of your ejected \`${name}\` generator (\`generators/${name}.mjs\`):`, + `This file is the DESIGN of your ejected \`${name}\` generator (\`${copy}\`):`, '**to change the generator, edit this skill first, then make the code match it** — a diff', - `to \`generators/${name}.mjs\` that has no covering sentence here is incomplete.`, + `to \`${copy}\` that has no covering sentence here is incomplete.`, ].join('\n'); const modifyLoop = [ '## The modify loop', '', '1. Edit this skill: state the new behavior or decision.', - `2. Make \`generators/${name}.mjs\` match it.`, + `2. Make \`${copy}\` match it.`, '3. Run `redocly generate-client` and inspect the `git diff` of the generated output —', ' generated files are never hand-edited.', '', diff --git a/packages/client-generator/scripts/generate-eject-assets.mjs b/packages/client-generator/scripts/generate-eject-assets.mjs index 89aa60fb34..6270b14e97 100644 --- a/packages/client-generator/scripts/generate-eject-assets.mjs +++ b/packages/client-generator/scripts/generate-eject-assets.mjs @@ -1,21 +1,21 @@ import { build } from 'esbuild'; import { spawnSync } from 'node:child_process'; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import ts from 'typescript'; import { ejectedSkill } from './ejected-skill.mjs'; -// Build the ejectable generator assets — one `.mjs` per built-in generator, which -// `redocly eject-generator ` copies into the user's repo verbatim. Two shapes, -// because the generators have two shapes: +// Build the ejectable generator assets, 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). +// - A language generator is a self-contained FOLDER of TypeScript stage files, so it +// ships as that folder — source copied byte-for-byte (the source already imports the +// public package entries), runnable under Node's native type stripping. The user +// reads their own generator, exactly as we wrote it. +// - A TypeScript generator is a thin entry over shared modules, so it ships BUNDLED +// into one `.mjs` (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. // @@ -25,6 +25,7 @@ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); const { version } = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')); const outDir = join(pkgRoot, 'eject-assets', 'generators'); const skillsDir = join(pkgRoot, 'eject-assets', 'skills'); +rmSync(outDir, { recursive: true, force: true }); mkdirSync(outDir, { recursive: true }); // The shared authoring skill ships as a skill too, so an agent in the user's repo loads @@ -138,10 +139,13 @@ function checkSyntax(outFile, name) { } /** The generator's design, rewritten for the user's repo and shipped as an agent skill. */ -function writeSkill(name) { +function writeSkill(name, options) { 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)); + writeFileSync( + join(skillsDir, `${name}-generator`, 'SKILL.md'), + ejectedSkill(skill, name, options) + ); } const LANGUAGE = [ @@ -217,25 +221,26 @@ for (const { name, imports, run, sample, options, docs } of TYPESCRIPT) { } 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(`'../../printers/${name}.js'`, `'@redocly/client-generator/printers/${name}'`) - .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); + const sourceDir = join(pkgRoot, 'src', 'generators', name); + const assetDir = join(outDir, name); + mkdirSync(assetDir, { recursive: true }); + for (const file of readdirSync(sourceDir).filter((entry) => entry.endsWith('.ts'))) { + // The source is the asset: it already imports the public package entries and its + // sibling stages by `.ts` extension, so the copy runs under Node's type stripping. + // Every file carries the provenance header — `--update` merges per file and reads + // the version from the file it is merging. + const source = readFileSync(join(sourceDir, file), 'utf-8'); + const content = + provenanceHeader(name) + + source + + (file === 'index.ts' ? defaultExport(name, { run, sample, docs }) : ''); + const checked = ts.transpileModule(content, { reportDiagnostics: true }); + if (checked.diagnostics !== undefined && checked.diagnostics.length > 0) { + const message = ts.flattenDiagnosticMessageText(checked.diagnostics[0].messageText, '\n'); + process.stderr.write(`eject asset ${name}/${file} does not parse: ${message}\n`); + process.exit(1); + } + writeFileSync(join(assetDir, file), content); + } + writeSkill(name, { folder: true }); } diff --git a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts index f763aac2fe..38304f32d6 100644 --- a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts +++ b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts @@ -11,7 +11,7 @@ import { ejectedSkill } from '../../../scripts/ejected-skill.mjs'; // missing its modify-loop anchors, fails here. const generatorsDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -/** Language generators: one self-contained file, ejected as its own source. */ +/** Language generators: self-contained folders, ejected as their 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']; @@ -39,11 +39,10 @@ describe.each(LANGUAGE)('%s generator skill ships to users', (name) => { expect(readFileSync(skillPath, 'utf-8')).toContain(`runtime/${name}/`); }); - it('ships without repo-only references — the user has no index.ts, prepare, or vitest', () => { + it('ships without repo-only references — the user has no prepare script or vitest', () => { const asset = join(generatorsDir, '../../eject-assets/skills', `${name}-generator`, 'SKILL.md'); const shipped = readFileSync(asset, 'utf-8'); - expect(shipped).toContain(`generators/${name}.mjs`); - expect(shipped).not.toContain('index.ts'); + expect(shipped).toContain(`generators/${name}/`); expect(shipped).not.toContain('npm run prepare'); expect(shipped).not.toContain('vitest'); }); @@ -61,9 +60,14 @@ describe.each(TYPESCRIPT)('%s generator skill (bundled on eject)', (name) => { describe.each(EJECTABLE)('%s ships an eject asset', (name) => { const assetsDir = join(generatorsDir, '../../eject-assets'); + // A language generator ships as its source folder (entry index.ts); a TypeScript + // generator ships as one bundled .mjs. + const assetEntry = LANGUAGE.includes(name) + ? join(assetsDir, 'generators', name, 'index.ts') + : join(assetsDir, 'generators', `${name}.mjs`); it('has a generator asset and a skill beside it', () => { - expect(existsSync(join(assetsDir, 'generators', `${name}.mjs`))).toBe(true); + expect(existsSync(assetEntry)).toBe(true); const skill = readFileSync(join(assetsDir, 'skills', `${name}-generator`, 'SKILL.md'), 'utf-8'); expect(skill.startsWith(`---\nname: ${name}-generator\ndescription: `)).toBe(true); }); @@ -76,12 +80,12 @@ describe.each(EJECTABLE)('%s ships an eject asset', (name) => { 'utf-8' ); const source = readFileSync(join(generatorsDir, name, 'AGENTS.md'), 'utf-8'); - expect(shipped).toBe(ejectedSkill(source, name)); + expect(shipped).toBe(ejectedSkill(source, name, { folder: LANGUAGE.includes(name) })); }); it('declares the default export the resolver loads, with a version range', () => { // The bundled assets go through esbuild, which normalizes quotes — match either. - const asset = readFileSync(join(assetsDir, 'generators', `${name}.mjs`), 'utf-8'); + const asset = readFileSync(assetEntry, 'utf-8'); expect(asset).toMatch(new RegExp(`name: ['"]${name}['"]`)); expect(asset).toMatch(/requiresGenerator: ['"]\^\d+\.\d+\.\d+['"]/); expect(asset).toContain('Ejected from @redocly/client-generator@'); diff --git a/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts b/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts index 7a497e2ae4..3dc1b84243 100644 --- a/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts +++ b/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts @@ -28,7 +28,7 @@ describe.each(['python', 'go', 'php'])('%s folder dogfooding invariant', (langua const source = readFileSync(resolve(folder, name), 'utf-8'); const specifiers = [...source.matchAll(/from '([^']+)'/g)].map((match) => match[1]); const violations = specifiers.filter( - (specifier) => !allowed.has(specifier) && !/^\.\/[a-z-]+\.js$/.test(specifier) + (specifier) => !allowed.has(specifier) && !/^\.\/[a-z-]+\.ts$/.test(specifier) ); expect(violations, name).toEqual([]); } diff --git a/packages/client-generator/src/generators/go/client.ts b/packages/client-generator/src/generators/go/client.ts index 5a5e647f40..e9c125238e 100644 --- a/packages/client-generator/src/generators/go/client.ts +++ b/packages/client-generator/src/generators/go/client.ts @@ -8,7 +8,7 @@ import { } from '@redocly/client-generator'; import { exported, type GoPrinter } from '@redocly/client-generator/printers/go'; -import { GO, naming } from './naming.js'; +import { GO, naming } from './naming.ts'; /** The server URL as a Go expression: literals concatenated with declared-variable args. */ function serverUrlExpression(server: ServerModel): string { diff --git a/packages/client-generator/src/generators/go/descriptor.ts b/packages/client-generator/src/generators/go/descriptor.ts index abf3dfe3e0..74695e43d0 100644 --- a/packages/client-generator/src/generators/go/descriptor.ts +++ b/packages/client-generator/src/generators/go/descriptor.ts @@ -8,7 +8,7 @@ import { securityRequirements, } from '@redocly/client-generator'; -import { naming } from './naming.js'; +import { naming } from './naming.ts'; /** Go composite literal for one operation's security OR-alternatives. */ export function goSecurityLiteral(op: OperationModel, model: ApiModel): string | undefined { diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index 661eac3081..7ba12a02c5 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -20,16 +20,16 @@ import { import { exported, GoPrinter } from '@redocly/client-generator/printers/go'; import { GO_RUNTIME_SOURCE } from '@redocly/client-generator/runtime-sources'; -import { writeGoServers } from './client.js'; -import { goPaginationLiteral, goSecurityLiteral } from './descriptor.js'; -import { renderGoModels } from './models.js'; -import { GO, goOperationIdents, goPackageName, naming } from './naming.js'; -import { writeGoMethod } from './operations.js'; -import { writeGoPaginationWrappers } from './pagination.js'; -import { goType } from './types.js'; +import { writeGoServers } from './client.ts'; +import { goPaginationLiteral, goSecurityLiteral } from './descriptor.ts'; +import { renderGoModels } from './models.ts'; +import { GO, goOperationIdents, goPackageName, naming } from './naming.ts'; +import { writeGoMethod } from './operations.ts'; +import { writeGoPaginationWrappers } from './pagination.ts'; +import { goType } from './types.ts'; -export { renderGoModels } from './models.js'; -export { goType } from './types.js'; +export { renderGoModels } from './models.ts'; +export { goType } from './types.ts'; /** Strip the package clause and import lines/blocks so a section stitches into one file. */ function stripHeader(source: string): string { diff --git a/packages/client-generator/src/generators/go/models.ts b/packages/client-generator/src/generators/go/models.ts index 2a3bb0294b..2f15e8b754 100644 --- a/packages/client-generator/src/generators/go/models.ts +++ b/packages/client-generator/src/generators/go/models.ts @@ -12,8 +12,8 @@ import { } from '@redocly/client-generator'; import { exported, GoPrinter } from '@redocly/client-generator/printers/go'; -import { naming } from './naming.js'; -import { goType } from './types.js'; +import { naming } from './naming.ts'; +import { goType } from './types.ts'; function writeStruct( printer: GoPrinter, diff --git a/packages/client-generator/src/generators/go/operations.ts b/packages/client-generator/src/generators/go/operations.ts index 1ff6491842..0b2b82c847 100644 --- a/packages/client-generator/src/generators/go/operations.ts +++ b/packages/client-generator/src/generators/go/operations.ts @@ -14,8 +14,8 @@ import { } from '@redocly/client-generator'; import { exported, type GoPrinter } from '@redocly/client-generator/printers/go'; -import { GO, naming } from './naming.js'; -import { goType } from './types.js'; +import { GO, naming } from './naming.ts'; +import { goType } from './types.ts'; /** A query-value expression formatted to string for url.Values. */ export function goQueryFormat(expr: string, type: string): string { diff --git a/packages/client-generator/src/generators/go/pagination.ts b/packages/client-generator/src/generators/go/pagination.ts index 8975422108..0604ff9160 100644 --- a/packages/client-generator/src/generators/go/pagination.ts +++ b/packages/client-generator/src/generators/go/pagination.ts @@ -3,9 +3,9 @@ import { type DateType, type OperationModel } from '@redocly/client-generator'; import { exported, type GoPrinter } from '@redocly/client-generator/printers/go'; -import { naming } from './naming.js'; -import { goQueryFormat, pathArguments } from './operations.js'; -import { goType } from './types.js'; +import { naming } from './naming.ts'; +import { goQueryFormat, pathArguments } from './operations.ts'; +import { goType } from './types.ts'; /** `Pages` / `Items` iterators over the runtime's `iterPages`, hydrated via `reencode`. */ export function writeGoPaginationWrappers( diff --git a/packages/client-generator/src/generators/php/client.ts b/packages/client-generator/src/generators/php/client.ts index 413ed0fc2f..d4c11bdbc7 100644 --- a/packages/client-generator/src/generators/php/client.ts +++ b/packages/client-generator/src/generators/php/client.ts @@ -9,7 +9,7 @@ import { } from '@redocly/client-generator'; import type { PhpPrinter } from '@redocly/client-generator/printers/php'; -import { PHP, phpString, propertyName } from './naming.js'; +import { PHP, phpString, propertyName } from './naming.ts'; /** The server URL as a PHP expression: literals concatenated with declared-variable args. */ function serverUrlExpression(server: ServerModel): string { diff --git a/packages/client-generator/src/generators/php/descriptor.ts b/packages/client-generator/src/generators/php/descriptor.ts index 586d633d36..7115ca65c0 100644 --- a/packages/client-generator/src/generators/php/descriptor.ts +++ b/packages/client-generator/src/generators/php/descriptor.ts @@ -10,7 +10,7 @@ import { securityRequirements, } from '@redocly/client-generator'; -import { PHP, phpString } from './naming.js'; +import { PHP, phpString } from './naming.ts'; /** Security literal for the operations table, denormalized from the model's schemes. */ export function phpSecurityLiteral(op: OperationModel, model: ApiModel): string | undefined { diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index a9978be399..be22958be5 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -20,16 +20,16 @@ import { import { PhpPrinter } from '@redocly/client-generator/printers/php'; import { PHP_RUNTIME_SOURCE } from '@redocly/client-generator/runtime-sources'; -import { writeServers } from './client.js'; -import { phpPaginationLiteral, phpSecurityLiteral } from './descriptor.js'; -import { hydration, renderPhpModels } from './models.js'; -import { methodIdents, methodName, PHP, phpString, propertyName } from './naming.js'; -import { writePhpMethod } from './operations.js'; -import { writePhpPaginationWrappers } from './pagination.js'; -import { phpType } from './types.js'; +import { writeServers } from './client.ts'; +import { phpPaginationLiteral, phpSecurityLiteral } from './descriptor.ts'; +import { hydration, renderPhpModels } from './models.ts'; +import { methodIdents, methodName, PHP, phpString, propertyName } from './naming.ts'; +import { writePhpMethod } from './operations.ts'; +import { writePhpPaginationWrappers } from './pagination.ts'; +import { phpType } from './types.ts'; -export { renderPhpModels } from './models.js'; -export { phpType } from './types.js'; +export { renderPhpModels } from './models.ts'; +export { phpType } from './types.ts'; /** Drop the standalone header (Pages()` / `Items()` generators over the runtime's iterPages. */ export function writePhpPaginationWrappers( diff --git a/packages/client-generator/src/generators/php/types.ts b/packages/client-generator/src/generators/php/types.ts index 61dd620949..ab3742c1d0 100644 --- a/packages/client-generator/src/generators/php/types.ts +++ b/packages/client-generator/src/generators/php/types.ts @@ -12,7 +12,7 @@ import { unwrapNullable, } from '@redocly/client-generator'; -import { className } from './naming.js'; +import { className } from './naming.ts'; /** What a named schema renders as: a class, a native enum, or nothing (alias). */ export function classify(name: string, model: ApiModel): 'class' | 'enum' | 'other' { diff --git a/packages/client-generator/src/generators/python/client.ts b/packages/client-generator/src/generators/python/client.ts index 9a82af2cdf..2d24c8404f 100644 --- a/packages/client-generator/src/generators/python/client.ts +++ b/packages/client-generator/src/generators/python/client.ts @@ -13,10 +13,10 @@ import { } from '@redocly/client-generator'; import type { PythonPrinter } from '@redocly/client-generator/printers/python'; -import { fieldName, naming, operationIdents, PY } from './naming.js'; -import { writeMethod } from './operations.js'; -import { writePaginationWrappers } from './pagination.js'; -import { pythonType } from './types.js'; +import { fieldName, naming, operationIdents, PY } from './naming.ts'; +import { writeMethod } from './operations.ts'; +import { writePaginationWrappers } from './pagination.ts'; +import { pythonType } from './types.ts'; /** The server URL as a Python expression: literals concatenated with declared-variable args. */ function serverUrlExpression(server: ServerModel): string { diff --git a/packages/client-generator/src/generators/python/descriptor.ts b/packages/client-generator/src/generators/python/descriptor.ts index e74783b872..f13a7ca697 100644 --- a/packages/client-generator/src/generators/python/descriptor.ts +++ b/packages/client-generator/src/generators/python/descriptor.ts @@ -9,7 +9,7 @@ import { type OperationModel, } from '@redocly/client-generator'; -import { naming, PY } from './naming.js'; +import { naming, PY } from './naming.ts'; /** JSON → Python literal (dicts/lists/strings/numbers/bools/None). */ export function pythonLiteral(value: unknown): string { diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index d5ec812740..21f64400a8 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -17,18 +17,18 @@ import { import { PythonPrinter } from '@redocly/client-generator/printers/python'; import { PYTHON_RUNTIME_SOURCES } from '@redocly/client-generator/runtime-sources'; -import { writeClientClass, writePythonServers } from './client.js'; -import { paginationSpec, pythonLiteral } from './descriptor.js'; +import { writeClientClass, writePythonServers } from './client.ts'; +import { paginationSpec, pythonLiteral } from './descriptor.ts'; import { discriminatorRegistrations, pydanticDiscriminators, renderPythonModels, type PythonModels, -} from './models.js'; -import { operationIdents, PY } from './naming.js'; +} from './models.ts'; +import { operationIdents, PY } from './naming.ts'; -export { renderPythonModels, type PythonModels } from './models.js'; -export { pythonType } from './types.js'; +export { renderPythonModels, type PythonModels } from './models.ts'; +export { pythonType } from './types.ts'; export const pythonOptions: GeneratorOptionsSchema = { type: 'object', diff --git a/packages/client-generator/src/generators/python/models.ts b/packages/client-generator/src/generators/python/models.ts index 8c9ba83aa1..05e9128313 100644 --- a/packages/client-generator/src/generators/python/models.ts +++ b/packages/client-generator/src/generators/python/models.ts @@ -11,8 +11,8 @@ import { } from '@redocly/client-generator'; import { PythonPrinter } from '@redocly/client-generator/printers/python'; -import { className, fieldName, naming } from './naming.js'; -import { pythonType } from './types.js'; +import { className, fieldName, naming } from './naming.ts'; +import { pythonType } from './types.ts'; /** The model style the generator emits: plain dataclasses, or pydantic `BaseModel`s. */ export type PythonModels = 'dataclass' | 'pydantic'; diff --git a/packages/client-generator/src/generators/python/operations.ts b/packages/client-generator/src/generators/python/operations.ts index 9e2a697e34..8837770f10 100644 --- a/packages/client-generator/src/generators/python/operations.ts +++ b/packages/client-generator/src/generators/python/operations.ts @@ -12,9 +12,9 @@ import { } from '@redocly/client-generator'; import type { PythonPrinter } from '@redocly/client-generator/printers/python'; -import { envelopeHeaderSpecs } from './descriptor.js'; -import { METHOD_ARG_SLOTS, naming, PY } from './naming.js'; -import { pythonType } from './types.js'; +import { envelopeHeaderSpecs } from './descriptor.ts'; +import { METHOD_ARG_SLOTS, naming, PY } from './naming.ts'; +import { pythonType } from './types.ts'; export function writeMethod( printer: PythonPrinter, diff --git a/packages/client-generator/src/generators/python/pagination.ts b/packages/client-generator/src/generators/python/pagination.ts index 4c0602fe66..2d15e2d871 100644 --- a/packages/client-generator/src/generators/python/pagination.ts +++ b/packages/client-generator/src/generators/python/pagination.ts @@ -9,8 +9,8 @@ import { } from '@redocly/client-generator'; import type { PythonPrinter } from '@redocly/client-generator/printers/python'; -import { METHOD_ARG_SLOTS, naming, PY } from './naming.js'; -import { pythonType } from './types.js'; +import { METHOD_ARG_SLOTS, naming, PY } from './naming.ts'; +import { pythonType } from './types.ts'; /** `_pages` / `_items` iterator methods for a paginated operation. */ export function writePaginationWrappers( diff --git a/packages/client-generator/src/generators/python/types.ts b/packages/client-generator/src/generators/python/types.ts index afc50e8929..6c03f1a9c3 100644 --- a/packages/client-generator/src/generators/python/types.ts +++ b/packages/client-generator/src/generators/python/types.ts @@ -7,7 +7,7 @@ import { unwrapNullable, } from '@redocly/client-generator'; -import { className, naming } from './naming.js'; +import { className, naming } from './naming.ts'; /** The Python type annotation for a schema (anonymous complex shapes collapse to Any-ish). */ export function pythonType(schema: SchemaModel, dateType: DateType = 'string'): string { diff --git a/packages/client-generator/src/generators/resolve.ts b/packages/client-generator/src/generators/resolve.ts index 6f3bbe9501..d6c85a1b4b 100644 --- a/packages/client-generator/src/generators/resolve.ts +++ b/packages/client-generator/src/generators/resolve.ts @@ -166,6 +166,13 @@ async function importGenerator(specifier: string, configDir: string): Promise; try { module = (await import(target)) as Record; diff --git a/tests/e2e/generate-client/eject.test.ts b/tests/e2e/generate-client/eject.test.ts index 7485d6c37e..bf12f39639 100644 --- a/tests/e2e/generate-client/eject.test.ts +++ b/tests/e2e/generate-client/eject.test.ts @@ -48,7 +48,9 @@ describe('eject-generator (end-to-end)', () => { 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); + // A language generator ejects as its source folder — one file per stage, entry index.ts. + expect(existsSync(join(project, 'generators/php/index.ts'))).toBe(true); + expect(existsSync(join(project, 'generators/php/naming.ts'))).toBe(true); // Nothing extra is committed: the merge base comes from the version in the header. expect(existsSync(join(project, 'generators/.pristine'))).toBe(false); @@ -91,12 +93,14 @@ describe('eject-generator (end-to-end)', () => { ).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' + 'extends: []\nclient:\n generators:\n - typescript\n - ./generators/go/index.ts\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); + expect( + readFileSync(join(wired, 'redocly.yaml'), 'utf-8').match(/go\/index\.ts/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')); @@ -120,10 +124,10 @@ describe('eject-generator (end-to-end)', () => { expect(eject.status, eject.stderr).toBe(0); const output = eject.stderr + eject.stdout; expect(output).toContain('generators:'); - expect(output).toContain('./generators/go.mjs'); + expect(output).toContain('./generators/go/index.ts'); // 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' + 'Run it: redocly generate-client --output --generator ./generators/go/index.ts' ); expect(output).toContain('https://redocly.com/docs/cli/commands/eject-generator'); } finally { @@ -140,7 +144,7 @@ describe('eject-generator (end-to-end)', () => { 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('Edit generators/python/ 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']); @@ -167,7 +171,7 @@ describe('eject-generator (end-to-end)', () => { '--output', 'ejected/client.ts', '--generator', - './generators/php.mjs', + './generators/php/index.ts', ]); expect(ejected.status, ejected.stderr).toBe(0); expect(ejected.stderr).toContain('takes over the built-in generator'); @@ -215,29 +219,36 @@ describe('eject-generator (end-to-end)', () => { 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'); + it('--update merges a folder generator per file, keeping local edits', () => { + appendFileSync(join(project, 'generators/php/index.ts'), '// my local customization\n'); + appendFileSync(join(project, 'generators/php/naming.ts'), '// naming tweak\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( + expect(readFileSync(join(project, 'generators/php/index.ts'), 'utf-8')).toContain( '// my local customization' ); + expect(readFileSync(join(project, 'generators/php/naming.ts'), 'utf-8')).toContain( + '// naming tweak' + ); expect(readFileSync(skillPath, 'utf-8')).toContain('We keep the legacy auth header.'); + }, 60_000); - // A `.pristine/` copy from an older CLI still works as the base, and says it can go. + it('--update marks real conflicts, and a legacy .pristine base still works', () => { + // zod is a single-file eject, where the `.pristine/` copy from an older CLI still + // works as the merge base — and the report says it can go. const legacy = join(project, 'generators/.pristine'); mkdirSync(legacy, { recursive: true }); - const ejected = join(project, 'generators/php.mjs'); + const ejected = join(project, 'generators/zod.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(join(legacy, 'zod.mjs'), base.join('\n'), 'utf-8'); writeFileSync(ejected, mine.join('\n'), 'utf-8'); - const conflicted = run(project, ['eject-generator', 'php', '--update']); + const conflicted = run(project, ['eject-generator', 'zod', '--update']); expect(conflicted.status, conflicted.stderr).toBe(0); const output = conflicted.stderr + conflicted.stdout; expect(output).toContain('conflict'); @@ -260,7 +271,10 @@ describe('eject-generator from source (no bundle)', () => { { 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); + const copy = ['python', 'go', 'php'].includes(generator) + ? `generators/${generator}/index.ts` + : `generators/${generator}.mjs`; + expect(existsSync(join(project, copy))).toBe(true); } } finally { rmSync(project, { recursive: true, force: true }); 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 index acfed7323e..1508aba0ed 100644 --- 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 @@ -1,13 +1,13 @@ --- name: php-generator -description: Design of the ejected Redocly `php` client generator. Read it, and update it, before changing generators/php.mjs. +description: Design of the ejected Redocly `php` client generator. Read it, and update it, before changing generators/php/. --- # The `php` generator — its skill -This file is the DESIGN of your ejected `php` generator (`generators/php.mjs`): +This file is the DESIGN of your ejected `php` generator (`generators/php/`): **to change the generator, edit this skill first, then make the code match it** — a diff -to `generators/php.mjs` that has no covering sentence here is incomplete. +to `generators/php/` that has no covering sentence here is incomplete. ## What it emits @@ -103,7 +103,7 @@ $idempotencyKey` on mutating methods. ## The modify loop 1. Edit this skill: state the new behavior or decision. -2. Make `generators/php.mjs` match it. +2. Make `generators/php/` match it. 3. Run `redocly generate-client` and inspect the `git diff` of the generated output — generated files are never hand-edited. diff --git a/tests/e2e/generate-client/examples/ejected-generator/README.md b/tests/e2e/generate-client/examples/ejected-generator/README.md index 2d52856214..b22115ac2e 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/README.md +++ b/tests/e2e/generate-client/examples/ejected-generator/README.md @@ -1,7 +1,8 @@ # 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 vendoring story for generators: `redocly eject-generator php` copied the built-in PHP generator into `generators/php/` — TypeScript source, one file per stage — and this repo customized it. +Search `generators/php/index.ts` for `CUSTOMIZATION` to see the one-line change (a platform banner in the generated header). +Running a TypeScript generator uses Node's type stripping (Node 22.18 or 23.6 and newer). The generated client stays machine-owned: regenerate any time while preserving customization. The customization lives in the generator, not in its output. @@ -14,8 +15,8 @@ npm run update-generator # merge a newer generator version into the customize `.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. +`npm run update-generator` three-way-merges a newer generator version into this customized copy, file by file — clean hunks apply silently, real conflicts get standard `<<<<<<<` markers. +The merge base is the version recorded in each 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. +The ejected generator 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/php.mjs b/tests/e2e/generate-client/examples/ejected-generator/generators/php.mjs deleted file mode 100644 index 06b4262afe..0000000000 --- a/tests/e2e/generate-client/examples/ejected-generator/generators/php.mjs +++ /dev/null @@ -1,578 +0,0 @@ -// 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: output.path.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/generators/php/client.ts b/tests/e2e/generate-client/examples/ejected-generator/generators/php/client.ts new file mode 100644 index 0000000000..6f0c9295e8 --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/php/client.ts @@ -0,0 +1,63 @@ +// Ejected from @redocly/client-generator@0.3.7 — 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 `client` stage: the `Servers` helper class of one static method per +// declared server. + +import { + type ApiModel, + identifierFor, + type ServerModel, + serverUrlParts, +} from '@redocly/client-generator'; +import type { PhpPrinter } from '@redocly/client-generator/printers/php'; + +import { PHP, phpString, propertyName } from './naming.ts'; + +/** The server URL as a PHP expression: literals concatenated with declared-variable args. */ +function serverUrlExpression(server: ServerModel): string { + const parts = serverUrlParts(server).map((part) => + part.kind === 'literal' ? phpString(part.value) : `${'$'}${propertyName(part.name)}` + ); + return parts.join(' . '); +} + +/** One static method per declared server; server variables become named string arguments. */ +export function writeServers(printer: PhpPrinter, model: ApiModel): void { + const servers = model.servers ?? []; + if (servers.length === 0) return; + const usedNames = new Set(); + printer.line( + '/** The declared servers; variables default to the values from the description. */' + ); + printer.line('final class Servers'); + printer.block( + '{', + () => { + servers.forEach((server, index) => { + let name = identifierFor(server.description ?? `server${index + 1}`, { + style: 'camel', + reserved: PHP, + }); + if (usedNames.has(name)) name = `${name}${index + 1}`; + usedNames.add(name); + const params = server.variables.map( + (variable) => + `string ${'$'}${propertyName(variable.name)} = ${phpString(variable.default)}` + ); + if (index > 0) printer.blank(); + printer.line(`public static function ${name}(${params.join(', ')}): string`); + printer.block( + '{', + () => { + printer.line(`return ${serverUrlExpression(server)};`); + }, + '}' + ); + }); + }, + '}' + ); + printer.blank(); +} diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/php/descriptor.ts b/tests/e2e/generate-client/examples/ejected-generator/generators/php/descriptor.ts new file mode 100644 index 0000000000..5039232007 --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/php/descriptor.ts @@ -0,0 +1,58 @@ +// Ejected from @redocly/client-generator@0.3.7 — 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 `descriptor` stage: the operations-table array literals — security +// OR-alternatives, the pagination spec, and envelope-header coerce specs. + +import { + type ApiModel, + headerCoerceType, + identifierFor, + type NeutralPaginationRule, + type OperationModel, + securityRequirements, +} from '@redocly/client-generator'; + +import { PHP, phpString } from './naming.ts'; + +/** Security literal for the operations table, denormalized from the model's schemes. */ +export function phpSecurityLiteral(op: OperationModel, model: ApiModel): string | undefined { + const alternatives = securityRequirements(op, model).map((alternative) => { + const specs = alternative.map((spec) => + spec.kind === 'apiKey' + ? `['kind' => 'apiKey', 'scheme' => ${phpString(spec.scheme)}, 'name' => ${phpString(spec.name)}, 'in' => ${phpString(spec.in)}]` + : `['kind' => ${phpString(spec.kind)}, 'scheme' => ${phpString(spec.scheme)}]` + ); + return `[${specs.join(', ')}]`; + }); + if (alternatives.length === 0) return undefined; + return `[${alternatives.join(', ')}]`; +} + +export function phpPaginationLiteral(rule: NeutralPaginationRule): string { + const fields = [ + `'style' => ${phpString(rule.style)}`, + ...(rule.param !== undefined ? [`'param' => ${phpString(rule.param)}`] : []), + ...(rule.nextCursor !== undefined ? [`'nextCursor' => ${phpString(rule.nextCursor)}`] : []), + ...(rule.hasMore !== undefined ? [`'hasMore' => ${phpString(rule.hasMore)}`] : []), + ...(rule.limitParam !== undefined ? [`'limitParam' => ${phpString(rule.limitParam)}`] : []), + ...(rule.items !== undefined ? [`'items' => ${phpString(rule.items)}`] : []), + ]; + return `[${fields.join(', ')}]`; +} + +/** Declared response headers as runtime coerce specs: `[wire name, camelCase key, type]`. */ +export function envelopeHeaderSpecs(op: OperationModel, model: ApiModel): string { + const used = new Set(); + const specs = (op.successResponseHeaders ?? []).map((header) => { + let key = identifierFor(header.name, { style: 'camel', reserved: PHP }); + let suffix = 2; + while (used.has(key)) + key = `${identifierFor(header.name, { style: 'camel', reserved: PHP })}_${suffix++}`; + used.add(key); + const type = headerCoerceType(header.schema, model); + return `[${phpString(header.name)}, ${phpString(key)}, ${phpString(type)}]`; + }); + return `[${specs.join(', ')}]`; +} diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/php/index.ts b/tests/e2e/generate-client/examples/ejected-generator/generators/php/index.ts new file mode 100644 index 0000000000..cadfbb969b --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/php/index.ts @@ -0,0 +1,220 @@ +// Ejected from @redocly/client-generator@0.3.7 — 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 { + type CodeSample, + type Generator, + identifierFor, + jsonSuccessSchema, + type NeutralPaginationRule, + type OperationModel, + paginationItemSchema, + renderReferencePage, + type SampleContext, + sseResponse, +} from '@redocly/client-generator'; +import { PhpPrinter } from '@redocly/client-generator/printers/php'; +import { PHP_RUNTIME_SOURCE } from '@redocly/client-generator/runtime-sources'; + +import { writeServers } from './client.ts'; +import { phpPaginationLiteral, phpSecurityLiteral } from './descriptor.ts'; +import { hydration, renderPhpModels } from './models.ts'; +import { methodIdents, methodName, PHP, phpString, propertyName } from './naming.ts'; +import { writePhpMethod } from './operations.ts'; +import { writePhpPaginationWrappers } from './pagination.ts'; +import { phpType } from './types.ts'; + +export { renderPhpModels } from './models.ts'; +export { phpType } from './types.ts'; + +/** Drop the standalone header ( { + const printer = new PhpPrinter(); + const dateType = emit.dateType ?? 'string'; + const namespace = identifierFor(model.title, { style: 'pascal', reserved: PHP }); + printer.line('= 8.1, curl extension — zero Composer dependencies.' + ); + // CUSTOMIZATION: our platform banner — regeneration keeps it, `--update` merges around it. + printer.line('// Maintained by the Cafe platform team; see generators/php/.'); + 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 idents = methodIdents(model); + // Pagination arrives RESOLVED from the pipeline — one fit-verified answer per run. + const paginationRules = new Map(); + for (const op of operations) { + const spec = pagination?.get(op.name)?.spec; + if (spec !== undefined) paginationRules.set(op.name, spec); + } + + 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(); + + printer.doc('Client', `Client for ${model.title} (${model.version}).`); + // Not final: PHP test suites mock concrete classes (createMock(Client::class)). + printer.line('class Client'); + printer.block( + '{', + () => { + 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) { + const ident = idents.get(op.name)!; + writePhpMethod(printer, op, ident, model, dateType); + if (sseResponse(op) === undefined && (op.successResponseHeaders?.length ?? 0) > 0) { + writePhpMethod(printer, op, ident, model, dateType, true); + } + const rule = paginationRules.get(op.name); + if (rule === undefined) continue; + const success = jsonSuccessSchema(op); + const pageHydration = + success === undefined ? undefined : hydration(success, '$page', model, dateType); + const element = paginationItemSchema(success, rule.items, model); + const itemHydration = + element === undefined ? undefined : hydration(element, '$item', model, dateType); + writePhpPaginationWrappers( + printer, + op, + ident, + model, + dateType, + pageHydration, + itemHydration, + rule.items, + element === undefined ? 'mixed' : phpType(element, model, dateType) + ); + } + }, + '}' + ); + + return [{ path: output.path.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->${methodIdents(ctx.model).get(op.name) ?? 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, output, emit, pagination }) => [ + { + path: output.path.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: output.path }), + paginated: new Set(pagination?.keys() ?? []), + }), + }, +]; + +export default { + name: 'php', + run: phpGenerator, + sample: phpSample, + docs: phpDocs, + errorModes: ["throw"], + 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" + }, + requiresGenerator: '^0.3.7', +}; diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/php/models.ts b/tests/e2e/generate-client/examples/ejected-generator/generators/php/models.ts new file mode 100644 index 0000000000..234c0c9319 --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/php/models.ts @@ -0,0 +1,275 @@ +// Ejected from @redocly/client-generator@0.3.7 — 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 `models` stage: named schemas as promoted-constructor classes with +// fromArray/toArray hydration, native backed enums, and match-based union +// dispatchers — plus the wire↔typed value expressions the methods reuse. + +import { + type ApiModel, + type DateType, + deref, + discriminatorCases, + enumValues, + flattenAllOf, + type PropertyModel, + type SchemaModel, + uniqueIdentifiers, + unwrapNullable, +} from '@redocly/client-generator'; +import { PhpPrinter } from '@redocly/client-generator/printers/php'; + +import { className, PHP, phpString, propertyName } from './naming.ts'; +import { classify, isDateFormat, phpNullable, phpType } from './types.ts'; + +/** True when the named schema renders as an `unmarshalX` union dispatcher. */ +function isDiscriminatedUnion(name: string, model: ApiModel): boolean { + const named = model.schemas.find((candidate) => candidate.name === name); + return named !== undefined && discriminatorCases(named.schema, model) !== undefined; +} + +/** Wire value → typed value expression, or undefined when the raw value is already right. */ +export function hydration( + schema: SchemaModel, + expr: string, + model: ApiModel, + // Required on purpose: a defaulted `'string'` let a call site forget it, and the method + // then returned a raw string where its own signature declared `\DateTimeImmutable`. + dateType: DateType +): string | undefined { + const bare = unwrapNullable(schema); + if (dateType === 'Date' && bare.kind === 'scalar' && bare.scalar === 'string') { + if (isDateFormat(bare)) return `new \\DateTimeImmutable(${expr})`; + } + if (bare.kind === 'omit') + return hydration({ kind: 'ref', name: bare.base }, expr, model, dateType); + if (bare.kind === 'ref') { + const kind = classify(bare.name, model); + if (kind === 'class') return `${className(bare.name)}::fromArray(${expr})`; + if (kind === 'enum') return `${className(bare.name)}::from(${expr})`; + if (isDiscriminatedUnion(bare.name, model)) return `unmarshal${className(bare.name)}(${expr})`; + const target = deref(bare, model); + return target === undefined ? undefined : hydration(target, expr, model, dateType); + } + if (bare.kind === 'array') { + const item = hydration(bare.items, '$item', model, dateType); + if (item === undefined) return undefined; + return `array_map(static fn ($item) => ${item}, ${expr})`; + } + if (bare.kind === 'record') { + const item = hydration(bare.value, '$item', model, dateType); + if (item === undefined) return undefined; + return `array_map(static fn ($item) => ${item}, ${expr})`; + } + return undefined; +} + +/** Typed value → wire value expression, or undefined when it serializes as-is. */ +export function serialization( + schema: SchemaModel, + expr: string, + model: ApiModel, + dateType: DateType = 'string' +): string | undefined { + const bare = unwrapNullable(schema); + if (dateType === 'Date' && bare.kind === 'scalar' && bare.scalar === 'string') { + // A date-only value must not gain a time component on the way out. + if (bare.metadata?.format === 'date') return `${expr}->format('Y-m-d')`; + if (bare.metadata?.format === 'date-time') { + return `${expr}->format(\\DateTimeInterface::ATOM)`; + } + } + if (bare.kind === 'omit') { + return serialization({ kind: 'ref', name: bare.base }, expr, model, dateType); + } + if (bare.kind === 'ref') { + const kind = classify(bare.name, model); + if (kind === 'class') return `${expr}->toArray()`; + if (kind === 'enum') return `${expr}->value`; + // A union value may be a hydrated member instance or a raw (default-case) array. + if (isDiscriminatedUnion(bare.name, model)) { + return `is_object(${expr}) ? ${expr}->toArray() : ${expr}`; + } + const target = deref(bare, model); + return target === undefined ? undefined : serialization(target, expr, model, dateType); + } + if (bare.kind === 'array' || bare.kind === 'record') { + const inner = bare.kind === 'array' ? bare.items : bare.value; + const item = serialization(inner, '$item', model, dateType); + if (item === undefined) return undefined; + return `array_map(static fn ($item) => ${item}, ${expr})`; + } + return undefined; +} + +function writeClass( + printer: PhpPrinter, + name: string, + properties: PropertyModel[], + model: ApiModel, + dateType: DateType, + description?: string +): void { + // PHP requires defaulted parameters after required ones. + const ordered = [ + ...properties.filter((property) => property.required), + ...properties.filter((property) => !property.required), + ]; + printer.doc(className(name), description); + printer.line(`final class ${className(name)}`); + printer.block( + '{', + () => { + printer.block( + 'public function __construct(', + () => { + for (const property of ordered) { + const type = phpType(property.schema, model, dateType); + if (property.required) { + printer.line(`public ${type} ${'$'}${propertyName(property.name)},`); + } else { + const nullable = phpNullable(type); + printer.line(`public ${nullable} ${'$'}${propertyName(property.name)} = null,`); + } + } + }, + ') {' + ); + printer.line('}'); + printer.blank(); + + printer.line('public static function fromArray(array $data): self'); + printer.block( + '{', + () => { + printer.block( + 'return new self(', + () => { + for (const property of ordered) { + const raw = `$data[${phpString(property.name)}]`; + const typed = hydration(property.schema, raw, model, dateType); + const php = propertyName(property.name); + if (property.required) { + printer.line(`${php}: ${typed ?? raw},`); + } else if (typed === undefined) { + printer.line(`${php}: ${raw} ?? null,`); + } else { + printer.line(`${php}: isset(${raw}) ? ${typed} : null,`); + } + } + }, + ');' + ); + }, + '}' + ); + printer.blank(); + + printer.line('public function toArray(): array'); + printer.block( + '{', + () => { + printer.line('$data = [];'); + for (const property of ordered) { + const value = `$this->${propertyName(property.name)}`; + const wire = serialization(property.schema, value, model, dateType) ?? value; + if (property.required) { + printer.line(`$data[${phpString(property.name)}] = ${wire};`); + } else { + printer.block( + `if (${value} !== null) {`, + () => { + printer.line(`$data[${phpString(property.name)}] = ${wire};`); + }, + '}' + ); + } + } + printer.line('return $data;'); + }, + '}' + ); + }, + '}' + ); + printer.blank(); +} + +/** Render every named schema: classes (allOf flattened), native enums, union dispatchers. */ +export function renderPhpModels(model: ApiModel, dateType: DateType = 'string'): string { + const printer = new PhpPrinter(); + for (const { name, schema } of model.schemas) { + const asEnum = enumValues(schema); + if (asEnum !== undefined && (asEnum.scalar === 'string' || asEnum.scalar === 'integer')) { + const backing = asEnum.scalar === 'string' ? 'string' : 'int'; + printer.doc(className(name), schema.description); + printer.line(`enum ${className(name)}: ${backing}`); + printer.block( + '{', + () => { + // `1.5` and `15` fold to one pascal name; PHP rejects a duplicate case. + const members = uniqueIdentifiers( + asEnum.values.map((value) => String(value)), + { style: 'pascal', reserved: PHP } + ); + asEnum.values.forEach((value, index) => { + const literal = typeof value === 'string' ? phpString(value) : String(value); + printer.line(`case ${members[index]} = ${literal};`); + }); + }, + '}' + ); + printer.blank(); + continue; + } + if (schema.kind === 'object' || schema.kind === 'intersection') { + const flat = flattenAllOf(schema, model); + if (flat !== undefined) { + writeClass( + printer, + name, + flat.properties, + model, + dateType, + flat.description ?? schema.description + ); + continue; + } + } + const cases = discriminatorCases(schema, model); + if (cases !== undefined) { + const typeName = className(name); + const table = cases.cases + .map((entry) => `${entry.value} -> ${className(entry.schemaName)}`) + .join(', '); + printer.line( + `/** ${typeName} is a discriminated union (${phpString(cases.property)}): ${table}. */` + ); + printer.line(`function unmarshal${typeName}(array $data): mixed`); + printer.block( + '{', + () => { + printer.block( + `return match ($data[${phpString(cases.property)}] ?? null) {`, + () => { + for (const entry of cases.cases) { + printer.line( + `${phpString(entry.value)} => ${className(entry.schemaName)}::fromArray($data),` + ); + } + printer.line('default => $data,'); + }, + '};' + ); + }, + '}' + ); + printer.blank(); + continue; + } + // Everything else (plain unions, aliases, records) has no PHP declaration; + // references resolve to the underlying type via phpType. + } + return printer.toString(); +} diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/php/naming.ts b/tests/e2e/generate-client/examples/ejected-generator/generators/php/naming.ts new file mode 100644 index 0000000000..7f88a057b5 --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/php/naming.ts @@ -0,0 +1,51 @@ +// Ejected from @redocly/client-generator@0.3.7 — 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 `naming` stage: the shared printer/naming instance, the string escaper, and +// the collision-free class/property/method identifiers every other stage builds on. + +import { + type ApiModel, + identifierFor, + type OperationModel, + RESERVED_WORDS, + uniqueIdentifiers, +} from '@redocly/client-generator'; +import { PhpPrinter } from '@redocly/client-generator/printers/php'; + +export const PHP = RESERVED_WORDS.php; + +// Naming and escaping delegate to the printer — one implementation, one policy. +export const naming = new PhpPrinter(); + +export function className(name: string): string { + return naming.typeName(name); +} + +export function propertyName(name: string): string { + return naming.memberName(name); +} + +/** `'…'` with backslashes and quotes escaped — safe for any spec-supplied text. */ +export function phpString(value: string): string { + return naming.string(value); +} + +export function methodName(op: OperationModel): string { + return identifierFor(op.name, { style: 'camel', reserved: PHP }); +} + +/** + * The method name for every operation, unique across the client — PHP fatals on a + * redeclared method, and two operationIds may camel-case to one name (`get-user`, + * `getUser`). Keyed by the IR name, which the sanitizer already made unique. + */ +export function methodIdents(model: ApiModel): Map { + const operations = model.services.flatMap((service) => service.operations); + const names = uniqueIdentifiers( + operations.map((op) => op.name), + { style: 'camel', reserved: PHP } + ); + return new Map(operations.map((op, index) => [op.name, names[index]])); +} diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/php/operations.ts b/tests/e2e/generate-client/examples/ejected-generator/generators/php/operations.ts new file mode 100644 index 0000000000..fd2f912426 --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/php/operations.ts @@ -0,0 +1,232 @@ +// Ejected from @redocly/client-generator@0.3.7 — 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 `operations` stage: one typed request method per operation, plus the +// argument planning and request prologue it shares with the pagination wrappers. + +import { + type ApiModel, + type DateType, + isMultipartBody, + jsonSuccessSchema, + type OperationModel, + sseResponse, + uniqueIdentifiers, +} from '@redocly/client-generator'; +import type { PhpPrinter } from '@redocly/client-generator/printers/php'; + +import { envelopeHeaderSpecs } from './descriptor.ts'; +import { hydration, serialization } from './models.ts'; +import { PHP, phpString } from './naming.ts'; +import { phpElementType, phpNullable, phpType } from './types.ts'; + +const MUTATING = new Set(['post', 'put', 'patch']); + +type MethodArgs = { + pathArgs: Array<{ php: string; wire: string; type: string }>; + /** `value` is the expression to send: a date object formats itself, everything else is the variable. */ + queryArgs: Array<{ php: string; wire: string; type: string; value: string }>; + signature: string[]; +}; + +/** + * The argument names a request method declares beside its parameters. A parameter named + * after one of them takes a suffixed variable instead, so the slot keeps its meaning. + */ +const SIGNATURE_ARG_SLOTS = ['body', 'headers', 'idempotencyKey']; + +export function methodArgs( + op: OperationModel, + model: ApiModel, + includeBody: boolean, + dateType: DateType +): MethodArgs { + // Each parameter is its own argument, so path and query names share one namespace with + // the slots this signature declares itself (`$body`, `$headers`, `$idempotencyKey`). + // A repeat moves aside (`$id`, `$id_2`): PHP rejects a redefined parameter outright, and + // a description may legally use one name in two locations. + const names = uniqueIdentifiers( + [...op.pathParams, ...op.queryParams].map((param) => param.name), + { style: 'camel', reserved: PHP, taken: SIGNATURE_ARG_SLOTS } + ); + const pathArgs = op.pathParams.map((param, index) => ({ + php: names[index], + wire: param.name, + type: phpType(param.schema, model, dateType), + })); + const queryArgs = op.queryParams.map((param, index) => { + const php = names[op.pathParams.length + index]; + return { + php, + wire: param.name, + type: phpType(param.schema, model, dateType), + value: serialization(param.schema, `${'$'}${php}`, model, dateType) ?? `${'$'}${php}`, + }; + }); + const signature = [ + ...pathArgs.map(({ php, type }) => `${type} ${'$'}${php}`), + ...(includeBody && op.requestBody + ? [ + `${isMultipartBody(op) ? 'array' : phpType(op.requestBody.schema, model, dateType)} ${'$'}body`, + ] + : []), + ...queryArgs.map(({ php, type }) => { + const nullable = phpNullable(type); + return `${nullable} ${'$'}${php} = null`; + }), + '?array $headers = null', + ...(includeBody && MUTATING.has(op.method.toLowerCase()) + ? ['?string $idempotencyKey = null'] + : []), + ]; + return { pathArgs, queryArgs, signature }; +} + +/** The shared prologue: resolve auth, build query/url, merge headers. */ +function writeRequestSetup(printer: PhpPrinter, op: OperationModel, args: MethodArgs): void { + printer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`); + printer.line( + "[$authHeaders, $query, $cookies] = resolveAuth($op['security'] ?? [], $this->config->auth);" + ); + for (const { php, wire, value } of args.queryArgs) { + printer.block( + `if (${'$'}${php} !== null) {`, + () => { + printer.line(`$query[${phpString(wire)}] = ${value};`); + }, + '}' + ); + } + const pathDict = args.pathArgs + .map(({ php, wire }) => `${phpString(wire)} => ${'$'}${php}`) + .join(', '); + printer.line(`$url = buildUrl($this->config->serverUrl, $op['path'], [${pathDict}]);`); + printer.line('$requestHeaders = array_merge($authHeaders, $headers ?? []);'); + printer.block( + 'if ($cookies !== []) {', + () => { + printer.line("$requestHeaders['Cookie'] = implode('; ', $cookies);"); + }, + '}' + ); +} + +export function writePhpMethod( + printer: PhpPrinter, + op: OperationModel, + ident: string, + model: ApiModel, + dateType: DateType, + envelope = false +): void { + const args = methodArgs(op, model, true, dateType); + const sse = sseResponse(op); + const success = jsonSuccessSchema(op); + // Non-JSON success bodies (PDFs, images, octet streams) return the raw body string. + const rawBody = + sse === undefined && + success === undefined && + op.successResponses.some((response) => response.contentType !== ''); + const returnType = envelope + ? 'Envelope' + : sse !== undefined + ? '\\Generator' + : success !== undefined + ? phpType(success, model, dateType) + : rawBody + ? 'string' + : 'void'; + const name = envelope ? `${ident}WithHeaders` : ident; + const element = envelope ? undefined : phpElementType(success, model, dateType); + printer.doc( + name, + envelope + ? `Like ${ident}(), returning an Envelope with the declared response headers.` + : (op.summary ?? `${op.method.toUpperCase()} ${op.path}`), + element === undefined ? [] : [`@return ${element}[]`] + ); + printer.line(`public function ${name}(${args.signature.join(', ')}): ${returnType}`); + printer.block( + '{', + () => { + writeRequestSetup(printer, op, args); + if (sse !== undefined) { + const jsonData = sse.schema !== undefined && sse.schema.kind !== 'unknown'; + printer.line('$url = appendQuery($url, $query);'); + printer.block( + '$open = function (array $extraHeaders) use ($url, $requestHeaders): \\CurlHandle {', + () => { + printer.line('$handle = curl_init($url);'); + printer.line('$lines = [];'); + printer.block( + 'foreach (array_merge($requestHeaders, $extraHeaders) as $name => $value) {', + () => { + printer.line("$lines[] = $name . ': ' . $value;"); + }, + '}' + ); + printer.line('curl_setopt($handle, CURLOPT_HTTPHEADER, $lines);'); + printer.line('return $handle;'); + }, + '};' + ); + printer.line(`yield from iterSse($open, ${jsonData ? 'true' : 'false'});`); + return; + } + const request = [ + `'operationId' => $op['id']`, + `'method' => $op['method']`, + `'url' => $url`, + `'headers' => $requestHeaders`, + `'query' => $query`, + ]; + if (op.requestBody && isMultipartBody(op)) { + printer.line('[$contentType, $encoded] = toMultipart($body);'); + request.push(`'body' => $encoded`, `'contentType' => $contentType`); + } else if (op.requestBody) { + const wire = serialization(op.requestBody.schema, '$body', model, dateType) ?? '$body'; + printer.line(`$payload = json_encode(${wire});`); + request.push( + `'body' => $payload`, + `'contentType' => ${phpString(op.requestBody.contentType)}` + ); + } + if (MUTATING.has(op.method.toLowerCase()) && op.requestBody) { + request.push(`'idempotencyKey' => $idempotencyKey`); + } + printer.line(`$response = send($this->config, [${request.join(', ')}]);`); + printer.block( + "if ($response['status'] >= 400) {", + () => { + printer.line('throw apiErrorFrom($response);'); + }, + '}' + ); + const decoded = rawBody + ? "$response['body']" + : ((success === undefined + ? undefined + : hydration(success, 'decodeJson($response)', model, dateType)) ?? + 'decodeJson($response)'); + if (envelope) { + printer.line(`$data = ${decoded};`); + printer.line( + `return new Envelope(data: $data, headers: readEnvelopeHeaders($response, ${envelopeHeaderSpecs(op, model)}), status: $response['status']);` + ); + return; + } + if (rawBody) { + printer.line("return $response['body'];"); + return; + } + if (returnType === 'void') { + printer.line('decodeJson($response);'); + return; + } + printer.line(`return ${decoded};`); + }, + '}' + ); + printer.blank(); +} diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/php/pagination.ts b/tests/e2e/generate-client/examples/ejected-generator/generators/php/pagination.ts new file mode 100644 index 0000000000..642d70fff6 --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/php/pagination.ts @@ -0,0 +1,133 @@ +// Ejected from @redocly/client-generator@0.3.7 — 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 `pagination` stage: the `Pages()` / `Items()` generator methods +// over the runtime's iterPages. + +import { + type ApiModel, + type DateType, + jsonSuccessSchema, + type OperationModel, +} from '@redocly/client-generator'; +import type { PhpPrinter } from '@redocly/client-generator/printers/php'; + +import { phpString } from './naming.ts'; +import { methodArgs } from './operations.ts'; +import { phpType } from './types.ts'; + +/** `Pages()` / `Items()` generators over the runtime's iterPages. */ +export function writePhpPaginationWrappers( + printer: PhpPrinter, + op: OperationModel, + ident: string, + model: ApiModel, + dateType: DateType, + pageHydration: string | undefined, + itemHydration: string | undefined, + itemsPointer: string | undefined, + itemYield: string +): void { + const args = methodArgs(op, model, false, dateType); + const name = ident; + + const writeCall = () => { + printer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`); + printer.line('$base = [];'); + for (const { php, wire, value } of args.queryArgs) { + printer.block( + `if (${'$'}${php} !== null) {`, + () => { + printer.line(`$base[${phpString(wire)}] = ${value};`); + }, + '}' + ); + } + const pathDict = args.pathArgs + .map(({ php, wire }) => `${phpString(wire)} => ${'$'}${php}`) + .join(', '); + printer.block( + '$call = function (array $params) use ($op, $headers): array {', + () => { + printer.line( + "[$authHeaders, $authQuery, $cookies] = resolveAuth($op['security'] ?? [], $this->config->auth);" + ); + printer.line(`$url = buildUrl($this->config->serverUrl, $op['path'], [${pathDict}]);`); + printer.line('$requestHeaders = array_merge($authHeaders, $headers ?? []);'); + printer.block( + 'if ($cookies !== []) {', + () => { + printer.line("$requestHeaders['Cookie'] = implode('; ', $cookies);"); + }, + '}' + ); + printer.line( + "$response = send($this->config, ['operationId' => $op['id'], 'method' => $op['method'], 'url' => $url, 'headers' => $requestHeaders, 'query' => array_merge($params, $authQuery)]);" + ); + printer.block( + "if ($response['status'] >= 400) {", + () => { + printer.line('throw apiErrorFrom($response);'); + }, + '}' + ); + printer.line('return [decodeJson($response), $response];'); + }, + '};' + ); + }; + + const pageType = phpType(jsonSuccessSchema(op) ?? { kind: 'unknown' }, model, dateType); + const pageYield = pageType === 'mixed' ? 'mixed' : pageType; + printer.line('/**'); + printer.line(` * ${name} response pages, following the pagination rule automatically.`); + printer.line(' *'); + printer.line(` * @return \\Generator`); + printer.line(' */'); + printer.line(`public function ${name}Pages(${args.signature.join(', ')}): \\Generator`); + printer.block( + '{', + () => { + writeCall(); + printer.block( + "foreach (iterPages($call, $op['pagination'], $base) as $page) {", + () => { + printer.line(`yield ${pageHydration ?? '$page'};`); + }, + '}' + ); + }, + '}' + ); + printer.blank(); + + printer.line('/**'); + printer.line(` * The items of every ${name} page.`); + printer.line(' *'); + printer.line(` * @return \\Generator`); + printer.line(' */'); + printer.line(`public function ${name}Items(${args.signature.join(', ')}): \\Generator`); + printer.block( + '{', + () => { + writeCall(); + printer.block( + "foreach (iterPages($call, $op['pagination'], $base) as $page) {", + () => { + printer.line(`$items = resolvePointer($page, ${phpString(itemsPointer ?? '')});`); + printer.block( + 'foreach (is_array($items) ? $items : [] as $item) {', + () => { + printer.line(`yield ${itemHydration ?? '$item'};`); + }, + '}' + ); + }, + '}' + ); + }, + '}' + ); + printer.blank(); +} diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/php/types.ts b/tests/e2e/generate-client/examples/ejected-generator/generators/php/types.ts new file mode 100644 index 0000000000..c057a27fa8 --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/php/types.ts @@ -0,0 +1,148 @@ +// Ejected from @redocly/client-generator@0.3.7 — 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 `types` stage: the PHP type declaration for a schema, its nullable and +// union forms, and the element type PHP's own syntax erases. + +import { + type ApiModel, + type DateType, + deref, + enumValues, + flattenAllOf, + isNullable, + type SchemaModel, + unwrapNullable, +} from '@redocly/client-generator'; + +import { className } from './naming.ts'; + +/** What a named schema renders as: a class, a native enum, or nothing (alias). */ +export function classify(name: string, model: ApiModel): 'class' | 'enum' | 'other' { + const named = model.schemas.find((candidate) => candidate.name === name); + if (named === undefined) return 'other'; + const schema = named.schema; + const asEnum = enumValues(schema); + if (asEnum !== undefined && (asEnum.scalar === 'string' || asEnum.scalar === 'integer')) { + return 'enum'; + } + if ( + (schema.kind === 'object' || schema.kind === 'intersection') && + flattenAllOf(schema, model) !== undefined + ) { + return 'class'; + } + return 'other'; +} + +/** The PHP type declaration for a schema (arrays and unions widen to array/mixed). */ +export function phpType( + schema: SchemaModel, + model: ApiModel, + dateType: DateType = 'string' +): string { + if (isNullable(schema)) { + const inner = phpType(unwrapNullable(schema), model, dateType); + return phpNullable(inner); + } + switch (schema.kind) { + case 'scalar': + // Under `dateType: Date`, date and date-time become DateTimeImmutable — PHP's + // immutable date object parses and formats both wire shapes. + if (dateType === 'Date' && schema.scalar === 'string' && isDateFormat(schema)) { + return '\\DateTimeImmutable'; + } + return { string: 'string', integer: 'int', number: 'float', boolean: 'bool' }[schema.scalar]; + case 'array': + case 'record': + return 'array'; + case 'ref': { + const kind = classify(schema.name, model); + if (kind === 'class' || kind === 'enum') return className(schema.name); + const target = deref(schema, model); + return target === undefined ? 'mixed' : phpType(target, model, dateType); + } + case 'enum': + // Anonymous (inline) enums keep the wire scalar; only NAMED enums get types. + return { string: 'string', integer: 'int', number: 'float', boolean: 'bool' }[schema.scalar]; + case 'literal': + return typeof schema.value === 'string' + ? 'string' + : typeof schema.value === 'boolean' + ? 'bool' + : 'float'; + case 'omit': + // PHP has no Omit; the base class is the honest annotation. + return className(schema.base); + case 'union': + return phpUnionType(schema.members, model, dateType); + case 'null': + case 'object': + case 'intersection': + case 'unknown': + return 'mixed'; + } +} + +/** `date` or `date-time` — the two formats `dateType: Date` turns into objects. */ +export function isDateFormat(schema: SchemaModel): boolean { + const format = schema.metadata?.format; + return format === 'date' || format === 'date-time'; +} + +/** + * The nullable form of a PHP type. `?T` for a single type, `A|B|null` for a union — PHP + * forbids mixing `?` with `|`, and `mixed` already includes null. + */ +export function phpNullable(type: string): string { + if (type === 'mixed' || type.startsWith('?') || type.endsWith('|null')) return type; + return type.includes('|') ? `${type}|null` : `?${type}`; +} + +/** + * A union as a native PHP 8.1 union type (`int|string`, `PromotionType|array`). Rich list + * filters are usually unions, and collapsing them to `mixed` throws away the typing that + * makes the SDK worth generating. `mixed` cannot be a union member, so a member without a + * PHP type of its own (inline object, intersection, unknown) forces the whole union to + * `mixed`. Members that map to the same PHP type collapse to one. + */ +export function phpUnionType(members: SchemaModel[], model: ApiModel, dateType: DateType): string { + const rendered: string[] = []; + for (const member of members) { + // `null` is handled by the caller's nullability check, never as a member here. + if (member.kind === 'null') continue; + const type = phpType(member, model, dateType); + if (type === 'mixed') return 'mixed'; + // A nullable member inside a union contributes its bare type plus null. + const bare = type.startsWith('?') ? type.slice(1) : type; + if (!rendered.includes(bare)) rendered.push(bare); + if (type.startsWith('?') && !rendered.includes('null')) rendered.push('null'); + } + if (rendered.length === 0) return 'mixed'; + return rendered.join('|'); +} + +/** + * The element type behind a PHP type that erases it. `array` and `\Generator` are as + * specific as PHP's syntax gets, so the docblock carries what they hold — that is what + * static analysis and readers actually go by. + */ +export function phpElementType( + schema: SchemaModel | undefined, + model: ApiModel, + dateType: DateType +): string | undefined { + if (schema === undefined) return undefined; + const bare = unwrapNullable(schema); + if (bare.kind === 'ref') { + const target = deref(bare, model); + // A named schema that IS an array (a collection alias) keeps its element type. + return classify(bare.name, model) === 'other' + ? phpElementType(target, model, dateType) + : undefined; + } + if (bare.kind !== 'array') return undefined; + const element = phpType(bare.items, model, dateType); + return element === 'mixed' ? undefined : element; +} diff --git a/tests/e2e/generate-client/examples/ejected-generator/redocly.yaml b/tests/e2e/generate-client/examples/ejected-generator/redocly.yaml index 7b950ab8b4..c821a2d0ca 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/redocly.yaml +++ b/tests/e2e/generate-client/examples/ejected-generator/redocly.yaml @@ -6,4 +6,4 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - ./generators/php.mjs + - ./generators/php/index.ts diff --git a/tsconfig.json b/tsconfig.json index 8ed27b7a41..d62efebe8e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,6 +29,7 @@ }, "skipLibCheck": true, "moduleResolution": "nodenext", + "rewriteRelativeImportExtensions": true, "esModuleInterop": true }, "exclude": ["tests/e2e/generate-client/examples", "tests/e2e/generate-client/*-consumer"] From 0fa3f56d98833e3f6aa0188765493c7d56ca3250 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 23:17:18 +0300 Subject: [PATCH 28/35] feat!: remove the package runtime mode and the client runtime from the package root --- docs/@v2/commands/generate-client.md | 19 +- docs/@v2/configuration/reference/client.md | 2 +- .../@v2/guides/customize-client-generation.md | 2 +- docs/@v2/guides/use-generated-client.md | 28 +- .../__tests__/eject-generator.test.ts | 4 +- packages/cli/src/commands/generate-client.ts | 2 +- packages/cli/src/index.ts | 4 +- packages/client-generator/README.md | 26 +- .../scripts/ejected-skill.d.mts | 6 +- .../src/__tests__/entry-weight.test.ts | 59 ---- .../src/__tests__/index.test.ts | 6 +- .../src/generators/__tests__/index.test.ts | 36 +-- .../generators/cli/__tests__/render.test.ts | 35 +-- .../src/generators/cli/index.ts | 1 - .../src/generators/cli/render.ts | 13 +- .../client-generator/src/generators/meta.ts | 6 - .../src/generators/resolve.ts | 1 - .../client-generator/src/generators/types.ts | 8 +- .../client-assembly.test.ts.snap | 21 +- .../__tests__/client-assembly.test.ts | 73 ++--- .../typescript/__tests__/type-guards.test.ts | 33 ++- .../generators/typescript/client-assembly.ts | 90 ++---- packages/client-generator/src/index.ts | 49 +--- packages/client-generator/src/plugin.ts | 2 +- packages/client-generator/src/types.ts | 4 +- packages/core/src/types/redocly-yaml.ts | 2 +- tests/e2e/generate-client/cli-compose.test.ts | 3 +- .../examples/multi-instance/README.md | 12 +- .../examples/multi-instance/package.json | 3 - .../examples/multi-instance/redocly.yaml | 3 - .../examples/multi-instance/src/main.ts | 16 +- .../examples/package-runtime/.gitignore | 4 - .../examples/package-runtime/README.md | 19 -- .../examples/package-runtime/index.html | 11 - .../examples/package-runtime/package.json | 19 -- .../examples/package-runtime/redocly.yaml | 12 - .../examples/package-runtime/src/main.ts | 51 ---- .../examples/package-runtime/tsconfig.json | 4 - .../examples/package-runtime/vite.config.ts | 3 - tests/e2e/generate-client/extension.test.ts | 7 +- .../e2e/generate-client/package-mode.test.ts | 266 ------------------ .../package-runtime-cjs.test.ts | 86 ------ .../package-runtime-consumer/.gitignore | 2 - .../package-runtime-consumer/index.ts | 42 --- .../package-runtime-consumer/package.json | 6 - .../package-runtime-consumer/server.ts | 78 ----- .../package-runtime-consumer/tsconfig.json | 17 -- .../pagination-consumer/index-package.ts | 18 -- tests/e2e/generate-client/pagination.test.ts | 28 +- .../generate-client/per-instance-auth.test.ts | 7 +- .../generate-client/redocly-config.test.ts | 33 +-- .../generate-client/tanstack-query.test.ts | 78 ----- 52 files changed, 127 insertions(+), 1233 deletions(-) delete mode 100644 packages/client-generator/src/__tests__/entry-weight.test.ts delete mode 100644 tests/e2e/generate-client/examples/package-runtime/.gitignore delete mode 100644 tests/e2e/generate-client/examples/package-runtime/README.md delete mode 100644 tests/e2e/generate-client/examples/package-runtime/index.html delete mode 100644 tests/e2e/generate-client/examples/package-runtime/package.json delete mode 100644 tests/e2e/generate-client/examples/package-runtime/redocly.yaml delete mode 100644 tests/e2e/generate-client/examples/package-runtime/src/main.ts delete mode 100644 tests/e2e/generate-client/examples/package-runtime/tsconfig.json delete mode 100644 tests/e2e/generate-client/examples/package-runtime/vite.config.ts delete mode 100644 tests/e2e/generate-client/package-mode.test.ts delete mode 100644 tests/e2e/generate-client/package-runtime-cjs.test.ts delete mode 100644 tests/e2e/generate-client/package-runtime-consumer/.gitignore delete mode 100644 tests/e2e/generate-client/package-runtime-consumer/index.ts delete mode 100644 tests/e2e/generate-client/package-runtime-consumer/package.json delete mode 100644 tests/e2e/generate-client/package-runtime-consumer/server.ts delete mode 100644 tests/e2e/generate-client/package-runtime-consumer/tsconfig.json delete mode 100644 tests/e2e/generate-client/pagination-consumer/index-package.ts diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index f2f5d41540..e3a264ad19 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -76,7 +76,7 @@ redocly generate-client [--help] [--version] | `api` | string | The file path to the OpenAPI description, a URL, or an `apis:` alias. Omit it to generate a client for each api that has a `client` block or `clientOutput`. | | `--output`, `-o` | string | The output path (it must end in `.ts`). In multi-file modes, this is the entry file. Defaults to the `clientOutput` of the api, else `.client.ts` next to the configuration file. Use this option only when you generate one API. | | `--output-mode` | string | The file layout. See [Choose an output mode](#choose-an-output-mode).
**Possible values:** `single`, `split`. Default: `single`. | -| `--runtime` | string | The location of the client engine. See [Choose a runtime](#choose-a-runtime).
**Possible values:** `inline`, `package`. Default: `inline`. | +| `--runtime` | string | The location of the client engine.
**Possible values:** `inline`. Default: `inline`. | | `--import-ext` | string | The extension in the generated relative imports. See [Run with Node directly](../guides/use-generated-client.md#run-with-node-directly).
**Possible values:** `js` (the tsc/bundler convention), `ts` (for Node's built-in type stripping). Default: `js`. | | `--generator` | [string] | The generator to run: a built-in name, or the path or package of a custom generator. Repeat the flag to run more than one generator. Default value is `typescript`. See [Generators](../guides/use-generated-client.md#generators) for the full list. | | `--args-style` | string | Sets how you pass inputs to operations. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `grouped`, `flat`. Default: `grouped`. | @@ -141,23 +141,6 @@ The `--output-mode` flag controls how the command splits the client into files: redocly generate-client openapi.yaml -o src/api/client.ts --output-mode split ``` -Both modes work with both runtimes. - -### Choose a runtime - -The `--runtime` flag controls the location of the client engine (request building, auth, retries, middleware, SSE): - -- `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` 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)** - Learn how to use the client produced by the `generate-client` command diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index ecfa9b3db5..8f31d7faf3 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -26,7 +26,7 @@ As an alternative, pass `pagination` to the programmatic `generateClient(...)`. | ----------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `generators` | [string] | The generators to run, in order. Each entry is a built-in name (`typescript`, `zod`, `tanstack-query` or its `-vue`/`-svelte`/`-solid` variants, `swr`, `mock`, `transformers`, `cli`, `python`, `go`, `php`), or the path or package name of a custom generator. | | `outputMode` | string | The file layout: `single` or `split`. This option applies to TypeScript output only. The `python`, `go`, and `php` SDKs always emit one self-contained file. | -| `runtime` | string | The runtime distribution: `inline` or `package`. This option applies to TypeScript output only. The `python`, `go`, and `php` SDKs always embed their runtime. | +| `runtime` | string | The runtime distribution: `inline` (the runtime is embedded in the generated output). | | `importExt` | string | The extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). This option applies to TypeScript output only. | | `argsStyle` | string | How the client receives operation inputs: `grouped` (default) groups them by transport layer (`path`, `query`, `headers`, `cookies`, `body`), and `flat` merges them into one object. This option applies to TypeScript output only. Each language SDK follows its own idiom (keyword arguments, named arguments, a params struct). | | `errorMode` | string | How operations report HTTP errors: `throw` or `result`. The `python` SDK implements both. The `go` and `php` SDKs support only `throw`, because that is the language idiom, and they reject `result`. | diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 823b622333..cc9fe4093f 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -204,7 +204,7 @@ Your coding agent then has the contract, the model reference, and this helper ta TypeScript is one more output language. The `@redocly/client-generator/generate` entry exports the TypeScript-specific renderers. -These renderers are not on the package root, so the import graph of a `runtime: 'package'` client never includes the generation toolkit. +These renderers are not on the package root, which stays a small authoring surface. `tsType` is the schema-to-type renderer that the built-in `typescript` generator itself uses. Because of this, the mapping (refs, arrays, unions, formats, parenthesization) is exactly the same as in the generated client: diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index c878e578bf..4e6ea93048 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -161,8 +161,8 @@ Use this to add behavior that is not in a description, for example a `login` or The custom command lives in a file that you own: ```ts -import { runCli, type CustomCommand } from '@redocly/client-generator'; -import { SOURCES } from './src/cafe.ts'; // the composed entry exports its sources +import type { CustomCommand } from '@redocly/client-generator'; +import { runCli, SOURCES } from './src/cafe.ts'; // the composed entry exports its sources and the engine const login: CustomCommand = { name: 'login', @@ -405,30 +405,6 @@ Set `client.docsFrontmatter: true` to put YAML front matter with the title above For a different structure or wording, [eject the generator](../commands/eject-generator.md) that owns the page. The renderer is the template, so an ejected generator keeps writing its page and you own the layout. -## Package runtime - -By default, the generator embeds the runtime in the generated file, so the client is self-contained. -With [`--runtime package`](../commands/generate-client.md#choose-a-runtime), the generated file imports the runtime from `@redocly/client-generator` instead. -Your application code is **identical in both modes**: the same exports and the same call shapes. -Only the location of the engine changes. -Select `package` to get engine fixes and improvements through `npm update @redocly/client-generator`, with no regeneration. - -Install the runtime as a regular dependency and set the mode in `redocly.yaml`: - -```sh -npm install @redocly/client-generator -``` - -```yaml -client: - runtime: package # default: inline (self-contained) -``` - -If the generated file and the runtime are incompatible, your `tsc` build fails on the descriptor `satisfies` check. -The pair does not misbehave at runtime. -Package mode works with both output modes and every generator. -See the [`package-runtime` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/package-runtime). - ## Run with Node directly Node 22.7+ runs TypeScript natively with type stripping. diff --git a/packages/cli/src/commands/__tests__/eject-generator.test.ts b/packages/cli/src/commands/__tests__/eject-generator.test.ts index f3162ebba9..5fb3840b88 100644 --- a/packages/cli/src/commands/__tests__/eject-generator.test.ts +++ b/packages/cli/src/commands/__tests__/eject-generator.test.ts @@ -75,7 +75,7 @@ describe('wireConfig', () => { expect( wire(outdent` client: - runtime: package + errorMode: result apis: cafe: root: ./openapi.yaml @@ -84,7 +84,7 @@ describe('wireConfig', () => { client: generators: - ./generators/php/index.ts - runtime: package + errorMode: result apis: cafe: root: ./openapi.yaml diff --git a/packages/cli/src/commands/generate-client.ts b/packages/cli/src/commands/generate-client.ts index 13ea826a11..2ec3395606 100644 --- a/packages/cli/src/commands/generate-client.ts +++ b/packages/cli/src/commands/generate-client.ts @@ -32,7 +32,7 @@ export type GenerateClientCommandArgv = { config?: string; 'server-url'?: string; 'output-mode'?: 'single' | 'split'; - runtime?: 'inline' | 'package'; + runtime?: 'inline'; 'import-ext'?: 'js' | 'ts'; 'go-package'?: string; 'args-style'?: 'flat' | 'grouped'; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f2685b9349..5d52df9ffe 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -896,8 +896,8 @@ yargs(hideBin(process.argv)) }, runtime: { describe: - "Runtime distribution: 'inline' (default) embeds the runtime in the generated file; 'package' imports it from @redocly/client-generator.", - choices: ['inline', 'package'] as const, + "Runtime distribution: 'inline' (default) embeds the runtime in the generated file.", + choices: ['inline'] as const, requiresArg: true, }, docs: { diff --git a/packages/client-generator/README.md b/packages/client-generator/README.md index 31a2d7bb53..0a08789c3d 100644 --- a/packages/client-generator/README.md +++ b/packages/client-generator/README.md @@ -11,7 +11,7 @@ See https://github.com/Redocly/redocly-cli for the full project. The generated client uses only web-standard APIs (`fetch`, `AbortController`, `URLSearchParams`), so by default it is a single self-contained file with zero runtime dependencies that runs in browsers, Node ≥ 18, Bun, Deno, and edge runtimes. (Running the generator itself requires the Node version in this package's `engines` field.) Code is produced through the TypeScript compiler AST, not string templates; `typescript` is the only peer dependency — optional, needed only when you run generation, and it must be 6.x there (TypeScript 7's native compiler has no compiler API). -Apps that only consume a package-runtime client don't need it at all, and can compile the generated code with any TypeScript, including 7. +Apps that only consume a generated client don't need it at all, and can compile the generated code with any TypeScript, including 7. This package is the engine behind the [`generate-client` command](https://redocly.com/docs/cli/commands/generate-client) — install [`@redocly/cli`](https://www.npmjs.com/package/@redocly/cli) to run it from the command line or `redocly.yaml`. How to use the generated client — auth, middleware, retries, pagination, Server-Sent Events, and the add-on generators (`zod`, `tanstack-query`, `swr`, `mock`, `transformers`) — is documented in [Use the generated client](https://redocly.com/docs/cli/guides/use-generated-client). @@ -41,8 +41,7 @@ For type-safe authoring of a standalone options object, annotate it with `satisf The generated module exports its operation descriptors, so an app can build additional instances with independent configuration and credentials over the same generated code: ```ts -import { createClient } from '@redocly/client-generator'; -import { OPERATIONS, type Ops } from './client.ts'; +import { createClient, OPERATIONS, type Ops } from './client.ts'; const internal = createClient(OPERATIONS, { serverUrl: 'https://api.example.com', @@ -50,8 +49,6 @@ const internal = createClient(OPERATIONS, { }); ``` -With `runtime: 'package'` the generated client also imports its whole engine from this package (instead of embedding it), so engine fixes arrive via `npm update` — install this package as a regular dependency of the consuming app. - ### Write a custom generator A custom generator reads the same API model the built-ins consume, runs in the same pass, and returns files. @@ -116,7 +113,7 @@ type GenerateClientResult = { ### `collectGeneratedFiles` Runs the configured generators against a built model and returns the files in memory, without writing to disk. -Imported from `@redocly/client-generator/generate` — the generation-time entry; the package root stays runtime-only so package-mode clients never load the generator stack: +Imported from `@redocly/client-generator/generate` — the generation-time entry; the package root stays a small authoring surface: ```ts function collectGeneratedFiles( @@ -133,7 +130,7 @@ function collectGeneratedFiles( ### `defineGenerator` -Authors a custom generator (`{ name, run }` plus optional `requires`/`errorModes`/`dateTypes`/`runtimes` compatibility metadata, validated up front): +Authors a custom generator (`{ name, run }` plus optional `requires`/`errorModes`/`dateTypes` compatibility metadata, validated up front): ```ts function defineGenerator(generator: CustomGenerator): CustomGenerator; @@ -157,20 +154,9 @@ function defineClientSetup(setup: { A setup module may import only from `@redocly/client-generator`, so it never adds a dependency to the client (the import is stripped at generation time). -### `createClient` - -The runtime factory that `runtime: 'package'` clients import, also usable directly to build extra instances over generated descriptors (see [Basic usage](#build-extra-client-instances)): - -```ts -function createClient( - operations: Record, - config?: ClientConfig -): Client; -``` - ## Examples -Runnable examples — from a zero-install quickstart to middleware, publisher setup, SSE streaming, pagination, custom generators, and the package runtime — live in [`tests/e2e/generate-client/examples`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples). +Runnable examples — from a zero-install quickstart to middleware, publisher setup, SSE streaming, pagination, and custom generators — live in [`tests/e2e/generate-client/examples`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples). Each is a standalone Vite app with a checked-in, drift-checked generated client. ## Documentation @@ -191,4 +177,4 @@ npm run unit # unit tests (this package is held at 100% cover VITEST_SUITE=e2e npx vitest run tests/e2e/generate-client/ # behavioral e2e ``` -The client runtime lives in `src/runtime/` (real, unit-testable modules; package mode imports them, inline mode embeds them), the structural emitters in `src/emitters/`, the IR in `src/intermediate-representation/`, the generators in `src/generators/`, and the file-layout writers in `src/writers/`. +The client runtime lives in `src/runtime/` (real, unit-testable modules that generation embeds), the structural emitters in `src/emitters/`, the IR in `src/intermediate-representation/`, and the generators in `src/generators/`. diff --git a/packages/client-generator/scripts/ejected-skill.d.mts b/packages/client-generator/scripts/ejected-skill.d.mts index 0d318d1427..7b56a6e432 100644 --- a/packages/client-generator/scripts/ejected-skill.d.mts +++ b/packages/client-generator/scripts/ejected-skill.d.mts @@ -1,5 +1 @@ -export function ejectedSkill( - source: string, - name: string, - options?: { folder?: boolean } -): string; +export function ejectedSkill(source: string, name: string, options?: { folder?: boolean }): string; diff --git a/packages/client-generator/src/__tests__/entry-weight.test.ts b/packages/client-generator/src/__tests__/entry-weight.test.ts deleted file mode 100644 index 10e05c3f71..0000000000 --- a/packages/client-generator/src/__tests__/entry-weight.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -// Package-mode clients import the package ROOT at app runtime, and native ESM loads -// every static import eagerly — so the root entry's static graph must stay free of the -// generation stack (`typescript`, `@redocly/openapi-core`, Node builtins). It is -// reached only through the dynamic `import('./generate.js')` inside `generateClient`, -// which this walk deliberately does not follow. -const libDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../lib'); - -const STATIC_IMPORT = /(?:^|\n)(?:import|export)\s[^'"]*?from\s+['"]([^'"]+)['"]/g; - -function staticGraph(entry: string): { files: Set; externals: Set } { - const files = new Set(); - const externals = new Set(); - const queue = [entry]; - while (queue.length > 0) { - const file = queue.pop()!; - if (files.has(file)) continue; - files.add(file); - const source = readFileSync(file, 'utf-8'); - for (const match of source.matchAll(STATIC_IMPORT)) { - const specifier = match[1]; - if (specifier.startsWith('.')) queue.push(join(dirname(file), specifier)); - else externals.add(specifier); - } - } - return { files, externals }; -} - -describe('package root entry (lib/index.js)', () => { - it('statically loads only the runtime — no typescript, openapi-core, or Node builtins', () => { - const { files, externals } = staticGraph(join(libDir, 'index.js')); - expect([...externals]).toEqual([]); - const outsideRuntime = [...files].filter( - (file) => file.includes('/emitters/') || file.includes('/intermediate-representation/') - ); - expect(outsideRuntime).toEqual([]); - }); - - it('re-exports Envelope and EnvelopeResult for package-mode clients', () => { - // Package-mode sugar imports EnvelopeResult; the generated file re-exports Envelope. - const dts = readFileSync(join(libDir, 'index.d.ts'), 'utf-8'); - expect(dts).toMatch(/\bEnvelope\b/); - expect(dts).toMatch(/\bEnvelopeResult\b/); - }); -}); - -describe('runtime-sources entry (lib/runtime-sources.js)', () => { - it('statically loads only the generated source-string modules — ejected generators stay TS-free', () => { - const { files, externals } = staticGraph(join(libDir, 'runtime-sources.js')); - expect([...externals]).toEqual([]); - const outsideSources = [...files].filter( - (file) => !file.endsWith('runtime-sources.js') && !file.endsWith('-runtime-sources.js') - ); - expect(outsideSources).toEqual([]); - }); -}); diff --git a/packages/client-generator/src/__tests__/index.test.ts b/packages/client-generator/src/__tests__/index.test.ts index ef8d047e10..82934d5338 100644 --- a/packages/client-generator/src/__tests__/index.test.ts +++ b/packages/client-generator/src/__tests__/index.test.ts @@ -169,16 +169,16 @@ describe('collectGeneratedFiles', () => { } }); - it('supports runtime: package with outputMode: split (the shared emitter serves both)', () => { + it('supports outputMode: split with no schemas (only the entry file)', () => { const files = collectGeneratedFiles(model(), { outputPath: '/out/api.ts', outputMode: 'split', - emit: { runtime: 'package' }, + emit: {}, generators: ['typescript'], }); // No schemas in the model → only the entry file. expect(files.map((f) => f.path)).toEqual(['/out/api.ts']); - expect(files[0].content).toContain("from '@redocly/client-generator'"); + expect(files[0].content).toContain('// ─── Embedded runtime'); }); }); diff --git a/packages/client-generator/src/generators/__tests__/index.test.ts b/packages/client-generator/src/generators/__tests__/index.test.ts index 1f6616cffa..766ee0d439 100644 --- a/packages/client-generator/src/generators/__tests__/index.test.ts +++ b/packages/client-generator/src/generators/__tests__/index.test.ts @@ -77,7 +77,7 @@ describe('validateGenerators', () => { const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); try { // `outputMode` travels beside `emit`, hence the trailing argument. - validateGenerators(['php'], { runtime: 'package', argsStyle: 'grouped' }, undefined, 'split'); + validateGenerators(['php'], { runtime: 'inline', argsStyle: 'grouped' }, undefined, 'split'); const messages = warn.mock.calls.map(([message]) => message).join(''); expect(messages).toContain('the "php" generator ignores outputMode'); expect(messages).toContain('the "php" generator ignores runtime'); @@ -92,7 +92,7 @@ describe('validateGenerators', () => { warn.mockClear(); validateGenerators( ['typescript'], - { runtime: 'package', argsStyle: 'grouped' }, + { runtime: 'inline', argsStyle: 'grouped' }, undefined, 'split' ); @@ -136,38 +136,6 @@ describe('swr generator', () => { }); }); -describe('validateGenerators — runtime compatibility', () => { - /** A registry with one runtimes-restricted generator (no built-in restricts runtimes anymore). */ - function registryWith(runtimes: ('inline' | 'package')[]) { - const registry = builtinGenerators(); - registry.set('inline-only', { run: () => [], runtimes }); - return registry; - } - - it('rejects a runtimes-restricted generator with runtime: package, naming both', () => { - expect(() => - validateGenerators(['inline-only'], { runtime: 'package' }, registryWith(['inline'])) - ).toThrow(/"inline-only".*runtime "package".*inline/); - }); - - it('accepts a runtimes-restricted generator when the runtime matches (or is defaulted)', () => { - expect(() => - validateGenerators(['inline-only'], { runtime: 'inline' }, registryWith(['inline'])) - ).not.toThrow(); - expect(() => validateGenerators(['inline-only'], {}, registryWith(['inline']))).not.toThrow(); - }); - - it('accepts the wrapper generators with runtime: package (no longer restricted)', () => { - expect(() => - validateGenerators( - ['typescript', 'tanstack-query', 'swr'], - { runtime: 'package' }, - builtinGenerators() - ) - ).not.toThrow(); - }); -}); - describe('mock generator', () => { it('is registered and requires typescript', () => { expect(builtinGenerators().get('mock')?.requires).toContain('typescript'); diff --git a/packages/client-generator/src/generators/cli/__tests__/render.test.ts b/packages/client-generator/src/generators/cli/__tests__/render.test.ts index 7ebd4489b0..bea34bd9ef 100644 --- a/packages/client-generator/src/generators/cli/__tests__/render.test.ts +++ b/packages/client-generator/src/generators/cli/__tests__/render.test.ts @@ -224,7 +224,6 @@ describe('renderCliModule', () => { const options = { stem: 'client', importExt: 'js', - runtime: 'inline' as const, zodSelected: false, }; @@ -245,12 +244,8 @@ describe('renderCliModule', () => { expect(out).not.toContain('from "@redocly/client-generator"'); }); - it('package mode imports runCli from the package; zod co-selection wires validation', () => { - const out = renderCliModule(MODEL, { ...options, runtime: 'package', zodSelected: true }); - expect(out).toContain( - 'import { invokedName, runCli, type CliCommand, type CliWiring } from "@redocly/client-generator";' - ); - expect(out).not.toContain('function parseInvocation'); + it('zod co-selection wires validation', () => { + const out = renderCliModule(MODEL, { ...options, zodSelected: true }); expect(out).toContain('import { zodValidation } from "./client.zod.js";'); expect(out).toContain( 'use(zodValidation(process.argv.includes("--dry-run") ? { response: false } : {}));' @@ -288,32 +283,6 @@ describe('renderCliModule', () => { }); }); -describe('the package-mode import line', () => { - it('names only values the package root exports', async () => { - // The emitted entry is the only consumer of these names, and a missing export breaks - // every package-mode CLI at import time rather than at generation. - const out = renderCliModule(MODEL, { - stem: 'client', - importExt: 'js', - runtime: 'package', - zodSelected: false, - }); - const line = out - .split('\n') - .find((candidate) => candidate.includes('from "@redocly/client-generator"')); - expect(line, 'no package import line found').toBeDefined(); - const names = line! - .slice(line!.indexOf('{') + 1, line!.indexOf('}')) - .split(',') - .map((specifier) => specifier.trim()) - .filter((specifier) => specifier !== '' && !specifier.startsWith('type ')); - const root = (await import('../../../index.js')) as Record; - for (const name of names) { - expect(typeof root[name], `${name} is imported but not exported`).toBe('function'); - } - }); -}); - describe('renderComposedCliEntry', () => { it('keeps import bindings legal for digit-leading aliases and unique for colliding ones', () => { const out = renderComposedCliEntry( diff --git a/packages/client-generator/src/generators/cli/index.ts b/packages/client-generator/src/generators/cli/index.ts index 069719f650..10d413d62c 100644 --- a/packages/client-generator/src/generators/cli/index.ts +++ b/packages/client-generator/src/generators/cli/index.ts @@ -16,7 +16,6 @@ export const cliGenerator: Generator = ({ model, output, emit, selected, paginat const content = renderCliModule(model, { stem: output.stem, importExt: emit.importExt ?? 'js', - runtime: emit.runtime ?? 'inline', zodSelected: selected?.includes('zod') ?? false, pagination, argsStyle: emit.argsStyle ?? 'grouped', diff --git a/packages/client-generator/src/generators/cli/render.ts b/packages/client-generator/src/generators/cli/render.ts index ee4afcce37..1518f7c9f3 100644 --- a/packages/client-generator/src/generators/cli/render.ts +++ b/packages/client-generator/src/generators/cli/render.ts @@ -172,7 +172,6 @@ function codeJson(value: unknown, indent?: number): string { export type CliModuleOptions = { stem: string; importExt: string; - runtime: 'inline' | 'package'; zodSelected: boolean; pagination?: ModelPagination; /** The sibling client's call shape, which the dispatcher builds its inputs for. */ @@ -232,19 +231,12 @@ export function renderCliModule(model: ApiModel, options: CliModuleOptions): str HEADER, 'import { readFileSync, realpathSync, writeFileSync } from "node:fs";\nimport { fileURLToPath } from "node:url";', [ - ...(options.runtime === 'package' - ? [ - 'import { invokedName, runCli, type CliCommand, type CliWiring } from "@redocly/client-generator";', - ] - : []), `import { ${clientImports.join(', ')} } from "${clientModule}";`, ...(options.zodSelected ? [`import { zodValidation } from "./${options.stem}.zod.${options.importExt}";`] : []), ].join('\n'), - ...(options.runtime === 'inline' - ? ['// ─── Embedded cli engine (@redocly/client-generator) ───\n' + embedCliRuntime()] - : []), + '// ─── Embedded cli engine (@redocly/client-generator) ───\n' + embedCliRuntime(), `export const COMMANDS: CliCommand[] = ${codeJson(commands, 2)};`, ...(options.zodSelected ? [ @@ -341,6 +333,9 @@ ${entries.join('\n')} export const run = (argv: string[] = process.argv.slice(2)): Promise => runCli(SOURCES, argv); +// Re-exported so a wrapper (a custom \`login\` command) can run these sources itself. +export { runCli }; + ${ENTRY_GUARD}`, ].join('\n\n') + '\n' ); diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index f8478474d6..6bdffe67c2 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -150,7 +150,6 @@ export function validateSelection( } const errorMode = emit.errorMode ?? 'throw'; const dateType = emit.dateType ?? 'string'; - const runtime = emit.runtime ?? 'inline'; for (const name of names) { const descriptor = registry.get(name); if (!descriptor) { @@ -174,11 +173,6 @@ export function validateSelection( `The "${name}" generator requires --date-type ${descriptor.dateTypes.join(' or ')} (got "${dateType}") so the runtime values match the generated types.` ); } - if (descriptor.runtimes && !descriptor.runtimes.includes(runtime)) { - throw new NotSupportedError( - `The "${name}" generator does not support runtime "${runtime}" (supported: ${descriptor.runtimes.join(', ')}).` - ); - } // An option this generator can't apply is announced, not silently dropped. Only an // EXPLICIT value warns — defaults would nag every run. const chosen: Record = { ...emit, outputMode }; diff --git a/packages/client-generator/src/generators/resolve.ts b/packages/client-generator/src/generators/resolve.ts index d6c85a1b4b..5dca7456fb 100644 --- a/packages/client-generator/src/generators/resolve.ts +++ b/packages/client-generator/src/generators/resolve.ts @@ -151,7 +151,6 @@ function register(registry: Map, custom: CustomGene requires: custom.requires, errorModes: custom.errorModes, dateTypes: custom.dateTypes, - runtimes: custom.runtimes, }); } diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index ea029e139d..81b01fff7d 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -52,8 +52,8 @@ export type EmitOptions = { * via `mergeSetup`. Absent when no `--setup` is given. */ setup?: string; - /** Runtime distribution: 'inline' (default, self-contained) | 'package' (imports @redocly/client-generator). */ - runtime?: 'inline' | 'package'; + /** Runtime distribution: 'inline' (default) embeds the runtime in the generated file. */ + runtime?: 'inline'; /** * Extension used in generated relative import specifiers (the split entry's schemas * re-export and each satellite's sdk import). `'js'` (default) is the tsc/bundler @@ -200,7 +200,7 @@ export type SampleContext = { * * - `requires`: other generators that must also be selected (e.g. `tanstack-query` * imports the client's operation functions, so it requires `typescript`). - * - `errorModes` / `dateTypes` / `runtimes`: the subset this generator supports; + * - `errorModes` / `dateTypes`: the subset this generator supports; * `undefined` means "all". (`tanstack-query` wraps throw-mode functions, so it * supports only `throw` mode; `transformers` only type-checks when the client types * date fields as `Date`, so it supports only `dateType: 'Date'`.) @@ -225,8 +225,6 @@ export type GeneratorDescriptor = { requires?: string[]; errorModes?: ErrorMode[]; dateTypes?: DateType[]; - /** Runtime modes this generator supports; absent = compatible with both. */ - runtimes?: ('inline' | 'package')[]; /** * Options this generator does not apply, mapped to the reason it doesn't. Setting * one explicitly warns instead of being silently dropped — a global option diff --git a/packages/client-generator/src/generators/typescript/__tests__/__snapshots__/client-assembly.test.ts.snap b/packages/client-generator/src/generators/typescript/__tests__/__snapshots__/client-assembly.test.ts.snap index 2160d2aa2c..b0d1ebaa32 100644 --- a/packages/client-generator/src/generators/typescript/__tests__/__snapshots__/client-assembly.test.ts.snap +++ b/packages/client-generator/src/generators/typescript/__tests__/__snapshots__/client-assembly.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`emitClientSingleFile (package arm) > matches the golden output for a small model 1`] = ` +exports[`emitClientSingleFile (wiring) > matches the golden output for a small model 1`] = ` "// Generated by @redocly/client-generator — do not edit by hand. // Source: OpenAPI description. Re-run \`redocly generate-client\` to update. @@ -8,8 +8,6 @@ exports[`emitClientSingleFile (package arm) > matches the golden output for a sm * T (v1.0.0) */ -import { createClient, type OperationDescriptor } from '@redocly/client-generator'; - export type Order = { id: string; }; @@ -76,13 +74,10 @@ export const client = createClient matches the golden output for a paginated package client 1`] = ` +exports[`emitClientSingleFile — pagination > matches the golden output for a paginated client (wiring only) 1`] = ` "// Generated by @redocly/client-generator — do not edit by hand. // Source: OpenAPI description. Re-run \`redocly generate-client\` to update. @@ -90,8 +85,6 @@ exports[`emitClientSingleFile — pagination > matches the golden output for a p * T (v1.0.0) */ -import { createClient, type OperationDescriptor } from '@redocly/client-generator'; - export type Order = {}; export type Problem = {}; @@ -174,13 +167,10 @@ export const client = createClient matches the golden output for a result-mode paginated package client 1`] = ` +exports[`emitClientSingleFile — pagination > matches the golden output for a result-mode paginated client (wiring only) 1`] = ` "// Generated by @redocly/client-generator — do not edit by hand. // Source: OpenAPI description. Re-run \`redocly generate-client\` to update. @@ -188,8 +178,6 @@ exports[`emitClientSingleFile — pagination > matches the golden output for a r * T (v1.0.0) */ -import { createClient, type OperationDescriptor, type Result } from '@redocly/client-generator'; - export type Order = {}; export type Problem = {}; @@ -277,8 +265,5 @@ export const client = createClient { +describe('emitClientSingleFile (wiring)', () => { const output = emit(CAFE, { serverUrl: 'https://x' }); - it('imports from the package instead of inlining the runtime template', () => { - // Only the names the file references. The per-call option types went with the flat - // wrappers, and an unused type import fails a consumer's `noUnusedLocals` build. - expect(output).toContain( - "import { createClient, type OperationDescriptor } from '@redocly/client-generator';" - ); - expect(output).not.toContain('__send'); - expect(output).not.toContain('__buildUrl'); - expect(output).not.toContain('let BASE'); - }); - it('escapes U+2028/U+2029 in generated string literals (code-shape hardening)', () => { const out = emit( modelWith([getOrder], { @@ -188,15 +186,6 @@ describe('emitClientSingleFile (package arm)', () => { expect(output).toContain('configure_2 } = client;'); }); - it('re-exports the public surface', () => { - expect(output).toContain( - "export { ApiError, createClient, defaultRetryOn, TimeoutError } from '@redocly/client-generator';" - ); - expect(output).toContain( - "export type { ClientConfig, Envelope, Middleware, RequestOptions, ServerSentEvent, SseOptions } from '@redocly/client-generator';" - ); - }); - it('keys a path value by its WIRE name, which is what the runtime substitutes', () => { const model = modelWith([ operation({ @@ -229,14 +218,11 @@ describe('emitClientSingleFile (package arm)', () => { ); }); - it('layers a baked setup OVER the spec defaults and imports the contract types', () => { + it('layers a baked setup OVER the spec defaults', () => { const out = emit(modelWith([getOrder], { schemas: SCHEMAS }), { serverUrl: 'https://x', setup: '{ config: { retry: { retries: 2 } } }', }); - expect(out).toContain( - "import { createClient, mergeSetup, type ClientConfig, type Middleware, type OperationDescriptor } from '@redocly/client-generator';" - ); expect(out).toContain( 'const __redoclySetup: { config?: ClientConfig; middleware?: Middleware[] } = { config: { retry: { retries: 2 } } };' ); @@ -246,14 +232,10 @@ describe('emitClientSingleFile (package arm)', () => { ); }); - it('result mode with an SSE-only spec does not import the (unreferenced) Result type', () => { + it('result mode with an SSE-only spec keeps the SSE member unwrapped', () => { const out = emit(modelWith([streamEvents], { schemas: SCHEMAS }), { errorMode: 'result' }); - expect(out).not.toContain('type Result'); - // The SSE member stays unwrapped, and the re-export list still offers Result. expect(out).toContain('kind: "sse"'); - expect(out).toContain( - "export type { ClientConfig, Envelope, Middleware, RequestOptions, Result, ServerSentEvent, SseOptions } from '@redocly/client-generator';" - ); + expect(out).not.toContain('result: Result<'); }); it('bakes errorMode: result into the config and wraps Ops results', () => { @@ -265,7 +247,6 @@ describe('emitClientSingleFile (package arm)', () => { '{ serverUrl: "https://x", errorMode: "result", clientHeader: "redocly-client-generator" }' ); expect(out).toContain('result: Result;'); - expect(out).toContain('type Result'); }); it('argsStyle: flat merges the inputs and tells the runtime, keeping one binding', () => { @@ -345,7 +326,7 @@ describe('emitClientSingleFile (package arm)', () => { }); }); -describe('emitClientSingleFile (embed arm)', () => { +describe('emitClientSingleFile (embedded runtime)', () => { const output = emitClientSingleFile(CAFE, { serverUrl: 'https://x' }); it('embeds the runtime block instead of importing the package', () => { @@ -428,22 +409,8 @@ describe('emitClientSingleFile (embed arm)', () => { expect((sourceFile as unknown as { parseDiagnostics: unknown[] }).parseDiagnostics).toEqual([]); }); - it('emits wiring (Ops → OPERATIONS, client → sugar) byte-identical to the package arm', () => { - const packaged = emit(CAFE, { serverUrl: 'https://x' }); - // `'export type Ops ='` — the trailing `=` skips the embedded `export type OpsShape`. - // In embed mode the runtime block sits between OPERATIONS and `client`, so the - // wiring is compared as its two contiguous segments around it. - const slice = (out: string, from: string, to: number) => out.slice(out.indexOf(from), to); - expect( - slice(output, 'export type Ops =', output.indexOf('// ─── Embedded runtime')).trim() - ).toBe(slice(packaged, 'export type Ops =', packaged.indexOf('export const client')).trim()); - expect(slice(output, 'export const client', output.length).trim()).toBe( - slice(packaged, 'export const client', packaged.indexOf('export { ApiError,')).trim() - ); - }); - // The full inline output is not snapshotted here: the runtime bytes are pinned by - // runtime-sources.test.ts, the wiring by the byte-identity test above, and a real + // runtime-sources.test.ts, the wiring by the trimmed snapshots above, and a real // full inline client by the e2e cafe.snapshot.ts. }); @@ -452,7 +419,7 @@ describe('emitClientSingleFile — pagination', () => { const config = { operations: { listOrders: CURSOR_RULE } }; const pagination = resolveModelPagination(PAGINATED, config); - it('threads a config rule into the descriptor and the Ops item member (package arm)', () => { + it('threads a config rule into the descriptor and the Ops item member', () => { const out = emit(PAGINATED, { pagination }); expect(out).toContain( 'pagination: { style: "cursor", param: "cursor", nextCursor: "/nextCursor", items: "/orders" }' @@ -523,11 +490,11 @@ describe('emitClientSingleFile — pagination', () => { ); }); - it('matches the golden output for a paginated package client', () => { + it('matches the golden output for a paginated client (wiring only)', () => { expect(emit(PAGINATED, { pagination })).toMatchSnapshot(); }); - it('matches the golden output for a result-mode paginated package client', () => { + it('matches the golden output for a result-mode paginated client (wiring only)', () => { // Result mode: the Ops entry gains `page` (the raw page `.pages()` yields) next to // the envelope-wrapped `result`. expect(emit(PAGINATED, { pagination, errorMode: 'result' })).toMatchSnapshot(); diff --git a/packages/client-generator/src/generators/typescript/__tests__/type-guards.test.ts b/packages/client-generator/src/generators/typescript/__tests__/type-guards.test.ts index 8c2d2d44ad..4ac4712430 100644 --- a/packages/client-generator/src/generators/typescript/__tests__/type-guards.test.ts +++ b/packages/client-generator/src/generators/typescript/__tests__/type-guards.test.ts @@ -2,10 +2,15 @@ import { apiModel, namedSchema } from '../../../emitters/__tests__/fixtures.js'; import type { NamedSchemaModel, SchemaModel } from '../../../intermediate-representation/model.js'; import { emitClientSingleFile } from '../client-assembly.js'; -// The package arm keeps the emitted text free of the embedded runtime, so the -// absence assertions below test the schema types/guards alone. -const emitPackage: typeof emitClientSingleFile = (model, options = {}) => - emitClientSingleFile(model, { ...options, runtime: 'package' }); +// Cutting the embedded runtime block keeps the emitted text down to the schema +// types/guards these assertions target (the runtime bytes are pinned elsewhere). +const emitWiring: typeof emitClientSingleFile = (model, options = {}) => { + const out = emitClientSingleFile(model, options); + const start = out.indexOf('// ─── Embedded runtime'); + return start === -1 + ? out + : out.slice(0, start) + out.slice(out.indexOf('export const client =', start)); +}; describe('discriminated-union type guards (C6.4)', () => { const beverage = namedSchema('Beverage', { @@ -30,7 +35,7 @@ describe('discriminated-union type guards (C6.4)', () => { }); it('emits is() guards for an explicit discriminator', () => { - const out = emitPackage( + const out = emitWiring( apiModel({ schemas: [ beverage, @@ -59,7 +64,7 @@ describe('discriminated-union type guards (C6.4)', () => { }); it('skips a discriminator entry whose target is not a named schema', () => { - const out = emitPackage( + const out = emitWiring( apiModel({ schemas: [ beverage, @@ -82,7 +87,7 @@ describe('discriminated-union type guards (C6.4)', () => { }); it('emits a single guard when two discriminant values map to the same type', () => { - const out = emitPackage( + const out = emitWiring( apiModel({ schemas: [ namedSchema('Pet', { kind: 'object', properties: [] }), @@ -112,7 +117,7 @@ describe('discriminated-union type guards (C6.4)', () => { }); it('synthesizes an implicit discriminator from a shared distinct string const', () => { - const out = emitPackage( + const out = emitWiring( apiModel({ schemas: [ beverage, @@ -132,7 +137,7 @@ describe('discriminated-union type guards (C6.4)', () => { }); it('finds the implicit discriminant through intersection (allOf) members', () => { - const out = emitPackage( + const out = emitWiring( apiModel({ schemas: [ namedSchema('A', { @@ -297,11 +302,11 @@ describe('discriminated-union type guards (C6.4)', () => { ], ], ])('emits no guards when %s', (_reason, schemas) => { - expect(emitPackage(apiModel({ schemas }))).not.toContain('value is'); + expect(emitWiring(apiModel({ schemas }))).not.toContain('value is'); }); it('ignores non-literal properties while detecting the implicit discriminant', () => { - const out = emitPackage( + const out = emitWiring( apiModel({ schemas: [ namedSchema('R1', { @@ -374,7 +379,7 @@ describe('discriminated-union type guards (C6.4)', () => { { kind: 'object', properties: [{ name: 'pet', schema: catOrDog, required: true }] }, ], ])('emits guards for a discriminated union nested under %s', (_position, container) => { - const out = emitPackage(apiModel({ schemas: [cat, dog, namedSchema('PetBox', container)] })); + const out = emitWiring(apiModel({ schemas: [cat, dog, namedSchema('PetBox', container)] })); expect(out).toContain('export function isCat(value: Cat | Dog): value is Cat {'); expect(out).toContain('export function isDog(value: Cat | Dog): value is Dog {'); }); @@ -403,7 +408,7 @@ describe('discriminated-union type guards (C6.4)', () => { }, }, }); - const out = emitPackage( + const out = emitWiring( apiModel({ schemas: [ item('Ok', 'ok'), @@ -418,7 +423,7 @@ describe('discriminated-union type guards (C6.4)', () => { }); it('prefers the top-level named union param when a member also nests elsewhere', () => { - const out = emitPackage( + const out = emitWiring( apiModel({ schemas: [ beverage, diff --git a/packages/client-generator/src/generators/typescript/client-assembly.ts b/packages/client-generator/src/generators/typescript/client-assembly.ts index 3e9fd67c27..34d76ba45a 100644 --- a/packages/client-generator/src/generators/typescript/client-assembly.ts +++ b/packages/client-generator/src/generators/typescript/client-assembly.ts @@ -1,13 +1,9 @@ -// Client assembly, shared by both runtime distributions and both output modes. The -// wiring (descriptor map + `Ops` interface) is identical; only the runtime block -// differs — `runtime: 'package'` imports `createClient` from -// `@redocly/client-generator`, everything else (inline, the default) embeds the -// assembled runtime sources in its place (emitters/inline-runtime.ts). Single-file -// layout: runtime (import line | embedded block) → schema types → type guards → -// `*` aliases → Ops → OPERATIONS → (baked setup) → client instance → sugar → -// (package mode only) type re-exports — the embedded types are already exported in -// place, so the embed arm needs none. Split mode moves the schema types + guards into -// a sibling `.schemas.ts` the entry re-exports (`emitClientSplit`). +// Client assembly, shared by both output modes. The generated file embeds the +// assembled runtime sources (emitters/inline-runtime.ts). Single-file layout: +// schema types → type guards → `*` aliases → Ops → OPERATIONS → embedded +// runtime → (baked setup) → client instance → sugar — the embedded types are +// already exported in place, so no re-exports. Split mode moves the schema types + +// guards into a sibling `.schemas.ts` the entry re-exports (`emitClientSplit`). // Text templates throughout — no `typescript` at generate time. import { assembleInlineRuntime } from '../../emitters/inline-runtime.js'; @@ -30,8 +26,6 @@ import { import { renderTypeAliases } from './ts-type.js'; import { renderTypeGuards } from './type-guards.js'; -const PACKAGE_SPECIFIER = '@redocly/client-generator'; - export function emitClientSingleFile(model: ApiModel, options: EmitOptions = {}): string { return emitClient(model, options).entry; } @@ -57,7 +51,6 @@ function emitClient( options: EmitOptions, splitStem?: string ): { entry: string; schemas?: string } { - const embed = options.runtime !== 'package'; const ops = allOperations(model.services); const idents = packageIdents(model); // Resolved (and VERIFIED) up front: an explicit rule that doesn't fit throws here, @@ -72,7 +65,6 @@ function emitClient( pagination, }; const hasSse = ops.some((op) => op.sse !== undefined); - const hasRegular = ops.some((op) => op.sse === undefined); const wiring = ops.length > 0 @@ -86,17 +78,15 @@ function emitClient( 'export const OPERATIONS = {} as const satisfies Record;', ]; - const runtimeSection = embed - ? assembleInlineRuntime({ - multipart: ops.some((op) => op.requestBody && isTypedMultipart(op.requestBody)), - // Auth sugar needs schemes; `resolveAuth` fires when a descriptor carries - // `security` — a valid spec implies the former, but embed on either. - auth: model.securitySchemes.length > 0 || ops.some((op) => op.security.length > 0), - sse: hasSse, - setup: !!options.setup, - paginate: pagination.size > 0, - }) - : importLine(options, ctx, { hasRegular }); + const runtimeSection = assembleInlineRuntime({ + multipart: ops.some((op) => op.requestBody && isTypedMultipart(op.requestBody)), + // Auth sugar needs schemes; `resolveAuth` fires when a descriptor carries + // `security` — a valid spec implies the former, but embed on either. + auth: model.securitySchemes.length > 0 || ops.some((op) => op.security.length > 0), + sse: hasSse, + setup: !!options.setup, + paginate: pagination.size > 0, + }); const schemaSection = [ renderTypeAliases(model.schemas, ctx.dateType), renderTypeGuards(model.schemas), @@ -107,25 +97,21 @@ function emitClient( .filter((section) => section.length > 0) .join('\n\n'); const sugar = sugarSection(ops, idents); - // Embed mode exports its whole public surface in place; only the package arm re-exports. - const reexports = embed ? '' : reexportLines(ctx, hasSse); // Layout puts the reader's OWN API first (types → aliases → Ops → OPERATIONS) and the - // machinery after it. In embed mode the runtime block sits between the descriptors and - // the `client` initializer — after it for readability, before `client` so every - // declaration the module-init call chain touches (hoisted functions AND any future - // top-level const) is already evaluated; in package mode the import line leads. + // machinery after it. The runtime block sits between the descriptors and the `client` + // initializer — after it for readability, before `client` so every declaration the + // module-init call chain touches (hoisted functions AND any future top-level const) + // is already evaluated. if (splitStem === undefined) { return { entry: banner([ HEADER, renderTitleComment(model), - ...(embed ? [] : [runtimeSection]), [schemaSection, bodySection].filter((section) => section.length > 0).join('\n\n'), - ...(embed ? [runtimeSection] : []), + runtimeSection, clientSection(options, ctx, model), sugar, - reexports, ]), }; } @@ -138,12 +124,10 @@ function emitClient( hasSchemas ? schemaLinks(model, ctx, `./${splitStem}.schemas.${options.importExt ?? 'js'}`) : '', - ...(embed ? [] : [runtimeSection]), bodySection, - ...(embed ? [runtimeSection] : []), + runtimeSection, clientSection(options, ctx, model), sugar, - reexports, ]), schemas: hasSchemas ? banner([HEADER, renderTitleComment(model), schemaSection]) : undefined, }; @@ -161,20 +145,6 @@ function schemaLinks(model: ApiModel, ctx: EmitContext, specifier: string): stri return `${importLine}export * from '${specifier}';`; } -/** The single import from the runtime package — only names the file actually references. */ -function importLine(options: EmitOptions, ctx: EmitContext, refs: { hasRegular: boolean }): string { - const values = ['createClient', ...(options.setup ? ['mergeSetup'] : [])]; - const types = [ - ...(options.setup ? ['ClientConfig', 'Middleware'] : []), - 'OperationDescriptor', - // `Ops` wraps results in `Result` in result mode — but only NON-SSE members - // (an SSE-only spec would otherwise import it unused and fail noUnusedLocals). - ...(ctx.errorMode === 'result' && refs.hasRegular ? ['Result'] : []), - ].sort(); - const names = [...values, ...types.map((t) => `type ${t}`)].join(', '); - return `import { ${names} } from '${PACKAGE_SPECIFIER}';`; -} - /** The (optional) baked setup + the default `client` instance. */ function clientSection(options: EmitOptions, ctx: EmitContext, model: ApiModel): string { const serverUrl = options.serverUrl ?? model.serverUrl; @@ -230,21 +200,3 @@ function sugarSection(ops: OperationModel[], idents: Map): strin lines.push(`export const { ${names} } = client;`); return lines.join('\n'); } - -/** Public type surface re-exported for single-import DX (plus the `ApiError` class). */ -function reexportLines(ctx: EmitContext, hasSse: boolean): string { - const types = [ - 'ClientConfig', - 'Envelope', - 'Middleware', - 'RequestOptions', - ...(ctx.errorMode === 'result' ? ['Result'] : []), - ...(hasSse ? ['ServerSentEvent', 'SseOptions'] : []), - ].sort(); - return ( - // `createClient` is re-exported so package-mode consumers can build additional - // instances from the generated module alone — symmetric with inline output. - `export { ApiError, createClient, defaultRetryOn, TimeoutError } from '${PACKAGE_SPECIFIER}';\n` + - `export type { ${types.join(', ')} } from '${PACKAGE_SPECIFIER}';` - ); -} diff --git a/packages/client-generator/src/index.ts b/packages/client-generator/src/index.ts index 01b1161009..ebbe7146e0 100644 --- a/packages/client-generator/src/index.ts +++ b/packages/client-generator/src/index.ts @@ -1,10 +1,10 @@ -// The package ROOT entry — what package-mode clients load at app runtime, so its static -// import graph stays runtime-only (no `typescript`, no `@redocly/openapi-core`, no Node -// builtins; guarded by entry-weight.test.ts). The generation stack lives behind the dynamic -// import inside `generateClient` and the `@redocly/client-generator/generate` entry. +// The package ROOT entry — the authoring surface: the language-neutral toolkit, the +// plugin API, the user-facing config types, and the setup contract. Nothing imports +// this entry at app runtime — generated clients embed their runtime (ADR-0022) — and +// the TypeScript-emitting stack lives behind the dynamic import inside `generateClient` +// and the `@redocly/client-generator/generate` entry. -// The language-neutral generator-authoring toolkit — pure functions over the IR, -// safe on this runtime-only entry (no typescript, no openapi-core, no builtins). +// The language-neutral generator-authoring toolkit — pure functions over the IR. export * from './authoring/index.js'; export { NotSupportedError } from './errors.js'; export { defineClientSetup } from './runtime-contract.js'; @@ -18,40 +18,9 @@ export type { RetryContext, RetryStrategy, } from './runtime-contract.js'; -// The app-facing client runtime (package-mode clients import these from the package root). -// The setup-contract names above (Middleware, OperationContext, RequestContext, RetryConfig, -// RetryContext, RetryStrategy) are re-exports of the same runtime types — one definition, -// two entry points; the rest of the runtime's type surface is re-exported here. -export { - ApiError, - createClient, - defaultRetryOn, - mergeSetup, - TimeoutError, -} from './runtime/index.js'; -export type { - ApiErrorLike, - AuthCredentials, - Client, - ClientConfig, - ClientCore, - Envelope, - EnvelopeResult, - OperationDescriptor, - OperationMethodIdentity, - OpsShape, - ParamSpec, - ParseAs, - QueryValue, - RequestOptions, - Result, - SecuritySpec, - ServerSentEvent, - SseOptions, - TokenProvider, -} from './runtime/index.js'; -// The generated-CLI engine (package-mode cli files import it from the package root). -export { invokedName, runCli } from './runtime/cli.js'; +// The generated-CLI command shapes — authoring types for wrappers around a generated +// or composed CLI (a custom `login` command); the engine itself (`runCli`) is embedded +// in, and re-exported by, every generated cli module. export type { CliAuthScheme, CliCommand, diff --git a/packages/client-generator/src/plugin.ts b/packages/client-generator/src/plugin.ts index 66a8090bf9..517a982576 100644 --- a/packages/client-generator/src/plugin.ts +++ b/packages/client-generator/src/plugin.ts @@ -77,4 +77,4 @@ export type { // The TypeScript-emitting renderers (`tsType`, `operationSignature`, …) are exported from // `@redocly/client-generator/generate`, which also carries the generation entry point — -// the runtime-only package root stays free of it. +// the package root stays a small authoring surface. diff --git a/packages/client-generator/src/types.ts b/packages/client-generator/src/types.ts index 8fbb07270e..39c041bfc4 100644 --- a/packages/client-generator/src/types.ts +++ b/packages/client-generator/src/types.ts @@ -81,8 +81,8 @@ export type GenerateClientOptions = { * across all output modes. */ setup?: string; - /** Runtime distribution: 'inline' (default, self-contained) | 'package' (imports @redocly/client-generator). */ - runtime?: 'inline' | 'package'; + /** Runtime distribution: 'inline' (default) embeds the runtime in the generated file. */ + runtime?: 'inline'; /** 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`). */ diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index 05a6679447..042cac1201 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -377,7 +377,7 @@ const Client: NodeType = { argsStyle: { enum: ['flat', 'grouped'] }, serverUrl: { type: 'string' }, outputMode: { enum: ['single', 'split'] }, - runtime: { enum: ['inline', 'package'] }, + runtime: { enum: ['inline'] }, importExt: { enum: ['js', 'ts'] }, goPackage: { type: 'string' }, cliOutput: { type: 'string' }, diff --git a/tests/e2e/generate-client/cli-compose.test.ts b/tests/e2e/generate-client/cli-compose.test.ts index 4adddf3956..6af0fd89f4 100644 --- a/tests/e2e/generate-client/cli-compose.test.ts +++ b/tests/e2e/generate-client/cli-compose.test.ts @@ -45,9 +45,10 @@ beforeAll(() => { // 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 type { CustomCommand } from '@redocly/client-generator'; import * as shop from './shop.client.cli.ts'; import * as kitchen from './kitchen.client.cli.ts'; +const { runCli } = shop; // the engine is embedded in, and re-exported by, each generated cli module const login: CustomCommand = { name: 'login', diff --git a/tests/e2e/generate-client/examples/multi-instance/README.md b/tests/e2e/generate-client/examples/multi-instance/README.md index 0bf0d8b2ff..d6abf19250 100644 --- a/tests/e2e/generate-client/examples/multi-instance/README.md +++ b/tests/e2e/generate-client/examples/multi-instance/README.md @@ -1,13 +1,9 @@ # multi-instance example -Per-tenant client instances from one generated module: `createClient` (from -`@redocly/client-generator`) plus the generated `OPERATIONS` descriptors and -`Ops`/`OperationId`/… types build one isolated instance per tenant — each with its own -`serverUrl`, bearer token, and middleware. - -The generated module exports `createClient` in **both runtimes**, so the same pattern -works with the default `inline` mode (import it from the generated file instead). This -example uses `runtime: package` to also show the factory coming from the installed package. +Per-tenant client instances from one generated module: the generated `createClient` +factory plus the `OPERATIONS` descriptors and `Ops`/`OperationId`/… types build one +isolated instance per tenant — each with its own `serverUrl`, bearer token, and +middleware. Everything comes from the generated file; nothing is module-global. ## Run diff --git a/tests/e2e/generate-client/examples/multi-instance/package.json b/tests/e2e/generate-client/examples/multi-instance/package.json index 06c0325f6b..18ffb80191 100644 --- a/tests/e2e/generate-client/examples/multi-instance/package.json +++ b/tests/e2e/generate-client/examples/multi-instance/package.json @@ -8,9 +8,6 @@ "dev": "vite", "build": "vite build" }, - "dependencies": { - "@redocly/client-generator": "latest" - }, "devDependencies": { "@redocly/cli": "latest", "typescript": "^5.5.0", diff --git a/tests/e2e/generate-client/examples/multi-instance/redocly.yaml b/tests/e2e/generate-client/examples/multi-instance/redocly.yaml index dbc9df653b..48a3ebeb48 100644 --- a/tests/e2e/generate-client/examples/multi-instance/redocly.yaml +++ b/tests/e2e/generate-client/examples/multi-instance/redocly.yaml @@ -1,6 +1,4 @@ # redocly.yaml — drives `redocly generate-client` for this example. -# `runtime: package` because multi-instance needs `createClient`, which the -# package exports; the default `inline` mode keeps it module-local. apis: multi-instance: root: ./openapi.yaml @@ -8,4 +6,3 @@ apis: client: generators: - typescript - runtime: package diff --git a/tests/e2e/generate-client/examples/multi-instance/src/main.ts b/tests/e2e/generate-client/examples/multi-instance/src/main.ts index e8f35e2fdf..5b0dfe64b1 100644 --- a/tests/e2e/generate-client/examples/multi-instance/src/main.ts +++ b/tests/e2e/generate-client/examples/multi-instance/src/main.ts @@ -1,17 +1,11 @@ // multi-instance — one generated module, many isolated client instances. // -// The generated file exports the raw wiring — the `OPERATIONS` descriptors and the -// `Ops`/`OperationId`/`OperationPath`/`OperationTag` types — alongside its default -// `client`. With `runtime: package`, `createClient` is imported from -// `@redocly/client-generator`, so an app can build one instance per tenant, each -// with its own `serverUrl`, credentials, and middleware — nothing is module-global. -// -// (The generated module exports `createClient` in BOTH runtimes, so the same -// pattern works with the default `inline` mode too — this example uses -// `runtime: package` to also demonstrate importing the factory from the package.) -import { createClient } from '@redocly/client-generator'; - +// The generated file exports the raw wiring — the `createClient` factory, the +// `OPERATIONS` descriptors, and the `Ops`/`OperationId`/`OperationPath`/`OperationTag` +// types — alongside its default `client`, so an app can build one instance per tenant, +// each with its own `serverUrl`, credentials, and middleware — nothing is module-global. import { + createClient, OPERATIONS, type OperationId, type OperationPath, diff --git a/tests/e2e/generate-client/examples/package-runtime/.gitignore b/tests/e2e/generate-client/examples/package-runtime/.gitignore deleted file mode 100644 index 6eb23affb8..0000000000 --- a/tests/e2e/generate-client/examples/package-runtime/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -dist -src/api/ -package-lock.json diff --git a/tests/e2e/generate-client/examples/package-runtime/README.md b/tests/e2e/generate-client/examples/package-runtime/README.md deleted file mode 100644 index 25c941bf3d..0000000000 --- a/tests/e2e/generate-client/examples/package-runtime/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# package-runtime example - -Generated TypeScript client using **`runtime: package`**: the generated `src/api/client.ts` -contains only this API's types and operation descriptors and imports the engine -(`createClient`, `ApiError`, middleware, auth) from `@redocly/client-generator` — the example's -one real dependency. Engine fixes arrive via `npm update @redocly/client-generator` with no -regeneration; regenerate only when the API contract changes. - -## Run - -```bash -npm install -npm run generate # generate src/api (the client is gitignored) -npm run dev # open the printed local URL -``` - -The generated client under `src/api/` is gitignored; CI regenerates it and type-checks this example. -The app code is the same as the inline examples — `configure()`, `use()` middleware, free -functions, the `client` instance, `ApiError` — only the runtime's distribution differs. diff --git a/tests/e2e/generate-client/examples/package-runtime/index.html b/tests/e2e/generate-client/examples/package-runtime/index.html deleted file mode 100644 index 2cc6be9a3b..0000000000 --- a/tests/e2e/generate-client/examples/package-runtime/index.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - Redocly client-generator — package-runtime example - - -
Loading…
- - - diff --git a/tests/e2e/generate-client/examples/package-runtime/package.json b/tests/e2e/generate-client/examples/package-runtime/package.json deleted file mode 100644 index 0007c9d199..0000000000 --- a/tests/e2e/generate-client/examples/package-runtime/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@redocly-examples/package-runtime", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "generate": "redocly generate-client", - "dev": "vite", - "build": "vite build" - }, - "dependencies": { - "@redocly/client-generator": "latest" - }, - "devDependencies": { - "@redocly/cli": "latest", - "typescript": "^5.5.0", - "vite": "^5.4.0" - } -} diff --git a/tests/e2e/generate-client/examples/package-runtime/redocly.yaml b/tests/e2e/generate-client/examples/package-runtime/redocly.yaml deleted file mode 100644 index 2ba6882016..0000000000 --- a/tests/e2e/generate-client/examples/package-runtime/redocly.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# redocly.yaml — drives `redocly generate-client` for this example. -# `runtime: package` makes the generated client import the engine from -# `@redocly/client-generator` instead of inlining it — runtime fixes arrive -# via `npm update @redocly/client-generator`, no regeneration needed. -apis: - package-runtime: - root: ../_shared/cafe.yaml - clientOutput: ./src/api/client.ts - client: - generators: - - 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 deleted file mode 100644 index 3d3c8af8b7..0000000000 --- a/tests/e2e/generate-client/examples/package-runtime/src/main.ts +++ /dev/null @@ -1,51 +0,0 @@ -// package-runtime example — same app code as the inline examples, different distribution. -// -// This client was generated with `runtime: package`: instead of embedding the engine -// (fetch, retries, middleware, auth) in `src/api/client.ts`, the generated file contains -// only THIS API's types and operation descriptors and imports the engine from -// `@redocly/client-generator`. Engine fixes and improvements arrive with -// `npm update @redocly/client-generator` — regenerate only when the API contract changes. -import { ApiError, client, configure, listMenuItems, use } from './api/client.js'; - -configure({ serverUrl: 'https://api.cafe.redocly.com' }); - -const out = document.querySelector('#out')!; - -// Middleware runs inside the packaged engine but sees the generated OPERATIONS metadata: -// `ctx.operation.id` is the spec operationId, stable across engine updates. -const trace: string[] = []; -use({ - onRequest: (ctx) => { - trace.push(`→ ${ctx.operation.id} — ${ctx.method} ${ctx.url}`); - }, - onResponse: (response, ctx) => { - document.title = `cafe — ${ctx.operation.id} ${response.status}`; - }, -}); - -async function main() { - try { - // A typed call through a generated free function… - 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({ - path: { menuItemId: first.id }, - query: { photoSize: 'thumbnail' }, - }) - : undefined; - const photoLine = - photo instanceof Blob - ? `${first?.name} thumbnail: ${photo.type}, ${photo.size} bytes` - : photo; - out.textContent = [...trace, '', photoLine, '', JSON.stringify(menu.items, null, 2)].join('\n'); - } catch (error) { - out.textContent = - error instanceof ApiError - ? `ApiError ${error.status}: ${error.statusText}` - : `Unexpected error: ${String(error)}`; - } -} - -void main(); diff --git a/tests/e2e/generate-client/examples/package-runtime/tsconfig.json b/tests/e2e/generate-client/examples/package-runtime/tsconfig.json deleted file mode 100644 index 4bd6962d40..0000000000 --- a/tests/e2e/generate-client/examples/package-runtime/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../tsconfig.base.json", - "include": ["src"] -} diff --git a/tests/e2e/generate-client/examples/package-runtime/vite.config.ts b/tests/e2e/generate-client/examples/package-runtime/vite.config.ts deleted file mode 100644 index c049f46e10..0000000000 --- a/tests/e2e/generate-client/examples/package-runtime/vite.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineConfig } from 'vite'; - -export default defineConfig({}); diff --git a/tests/e2e/generate-client/extension.test.ts b/tests/e2e/generate-client/extension.test.ts index 54bf15f55e..f732265d72 100644 --- a/tests/e2e/generate-client/extension.test.ts +++ b/tests/e2e/generate-client/extension.test.ts @@ -86,10 +86,8 @@ describe('extension contract — flat surface (configure)', () => { describe('extension contract — per-instance config (createClient)', () => { let dir = ''; beforeAll(() => { - // The temp dir lives INSIDE the repo so the consumer's import of - // `@redocly/client-generator` resolves through the workspace node_modules symlink. dir = mkdtempSync(join(__dirname, '.tmp-ext-instance-')); - generateInto(dir, fixture, ['--runtime', 'package']); + generateInto(dir, fixture); }, 60_000); afterAll(() => { if (dir && existsSync(dir)) rmSync(dir, { recursive: true, force: true }); @@ -99,8 +97,7 @@ describe('extension contract — per-instance config (createClient)', () => { const calls = runConsumer( dir, outdent` - import { createClient } from '@redocly/client-generator'; - import { OPERATIONS, type Ops } from './client.ts'; + import { createClient, OPERATIONS, type Ops } from './client.ts'; const calls: Array<{ tag: string; url: string; tenant: string }> = []; const make = (tag: string) => diff --git a/tests/e2e/generate-client/package-mode.test.ts b/tests/e2e/generate-client/package-mode.test.ts deleted file mode 100644 index f8a6605be2..0000000000 --- a/tests/e2e/generate-client/package-mode.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { spawnSync, type ChildProcess } from 'node:child_process'; -import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -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 -// the workspace's own node_modules symlink — the spec's symlinked-consumer setup). -// The programmatic tests below exercise the API of the BUILT package; the CLI test -// exercises the `--runtime` flag wired in stage ③a. - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const generatorLib = join(repoRoot, 'packages/client-generator/lib/index.js'); -const fixture = join(__dirname, 'fixtures/package-runtime.yaml'); -const consumerDir = join(__dirname, 'package-runtime-consumer'); -const generatedFile = join(consumerDir, 'api.ts'); -const serverScript = join(consumerDir, 'server.ts'); -const indexScript = join(consumerDir, 'index.ts'); - -const SERVER_PORT = 3123; -const SERVER_BASE = `http://127.0.0.1:${SERVER_PORT}`; - -type GenerateClient = (options: Record) => Promise; - -async function loadGenerateClient(): Promise { - const mod = (await import(pathToFileURL(generatorLib).href)) as { - generateClient: GenerateClient; - }; - return mod.generateClient; -} - -describe('generate-client package-runtime consumer', () => { - let serverProcess: ChildProcess | undefined; - - beforeAll(async () => { - if (existsSync(generatedFile)) { - rmSync(generatedFile, { force: true }); - } - - serverProcess = await startServer( - serverScript, - consumerDir, - { PKG_SERVER_PORT: String(SERVER_PORT) }, - SERVER_BASE, - 'package-runtime-server' - ); - }, 30_000); - - afterAll(async () => { - if (serverProcess) { - await killServer(serverProcess); - } - }); - - test('end-to-end: generate package-mode client, type-check, run, assert wire behavior', async () => { - const generateClient = await loadGenerateClient(); - await generateClient({ api: fixture, output: generatedFile, runtime: 'package' }); - - expect(existsSync(generatedFile)).toBe(true); - const generated = readFileSync(generatedFile, 'utf-8'); - // Imports the runtime instead of inlining it. - expect(generated).toContain("from '@redocly/client-generator'"); - expect(generated).not.toContain('__send'); - expect(generated).not.toContain('let BASE'); - // The skew guard and the wire-name descriptor for the non-identifier path param. - expect(generated).toContain('as const satisfies Record;'); - expect(generated).toContain('{ name: "order-id", in: "path" }'); - // The colliding operationId is renamed; its descriptor id stays the spec id. - expect(generated).toContain('configure_2: { id: "configure"'); - - // Type gate: the consumer (incl. the generated file) compiles strict against the - // BUILT package types — this is the `satisfies` version-skew guard in action. - const typecheckResult = spawnSync('npx', ['tsc', '--noEmit', '-p', consumerDir], { - encoding: 'utf-8', - cwd: repoRoot, - }); - expect( - typecheckResult.status, - `tsc --noEmit failed:\nstdout:\n${typecheckResult.stdout}\nstderr:\n${typecheckResult.stderr}` - ).toBe(0); - - const runResult = spawnSync('npx', ['tsx', indexScript], { - encoding: 'utf-8', - cwd: consumerDir, - }); - expect( - runResult.status, - `consumer stdout:\n${runResult.stdout}\nstderr:\n${runResult.stderr}` - ).toBe(0); - const parsed = JSON.parse(runResult.stdout.trim()) as { - order: { id: string; status: string }; - grouped: { id: string }; - created: { id: string; status: string }; - collided: string; - events: Array<{ seq: number; text?: string }>; - middlewareIds: string[]; - }; - expect(parsed.order).toEqual({ id: 'o-1', status: 'open' }); - expect(parsed.grouped.id).toBe('o-2'); - expect(parsed.created).toEqual({ id: 'created-1', status: 'open' }); - expect(parsed.collided).toBe('ok'); - expect(parsed.events).toEqual([ - { seq: 1, text: 'a' }, - { seq: 2, text: 'b' }, - ]); - // Middleware targets the SPEC operationId — including the renamed `configure` op. - expect(parsed.middlewareIds).toEqual([ - 'getOrder', - 'getOrder', - 'createOrder', - 'configure', - 'streamEvents', - ]); - - const log = await serverLog>( - SERVER_BASE - ); - - // Wire-name path substitution + query serialization + injected bearer. - expect(log).toContainEqual({ - method: 'GET', - url: '/orders/o-1?expand=items', - auth: 'Bearer test-token', - }); - expect(log).toContainEqual({ method: 'GET', url: '/orders/o-2', auth: 'Bearer test-token' }); - // Unsecured operations carry no credential. - expect(log).toContainEqual({ method: 'POST', url: '/orders', auth: null }); - expect(log).toContainEqual({ method: 'GET', url: '/configure-op', auth: null }); - expect(log).toContainEqual({ method: 'GET', url: '/events', auth: null }); - }, 60_000); - - test('package mode composes with split output and the tanstack-query generator', async () => { - const generateClient = await loadGenerateClient(); - const tmpDir = mkdtempSync(join(tmpdir(), 'ots-package-combos-')); - try { - // split: the entry re-exports a sibling schemas module, both in package mode. - const splitEntry = join(tmpDir, 'api.ts'); - await generateClient({ - api: fixture, - output: splitEntry, - runtime: 'package', - outputMode: 'split', - }); - expect(existsSync(splitEntry)).toBe(true); - expect(readFileSync(splitEntry, 'utf-8')).toContain("from '@redocly/client-generator'"); - const splitSchemas = join(tmpDir, 'api.schemas.ts'); - expect(existsSync(splitSchemas)).toBe(true); - expect(readFileSync(splitSchemas, 'utf-8')).toContain('export type Order ='); - - // tanstack-query + package: the client plus the tanstack wrapper module. - const tanstackDir = join(tmpDir, 'tanstack'); - const tanstackEntry = join(tanstackDir, 'api.ts'); - await generateClient({ - api: fixture, - output: tanstackEntry, - runtime: 'package', - generators: ['typescript', 'tanstack-query'], - }); - expect(existsSync(tanstackEntry)).toBe(true); - expect(readFileSync(tanstackEntry, 'utf-8')).toContain("from '@redocly/client-generator'"); - const tanstackWrapper = join(tanstackDir, 'api.tanstack.ts'); - expect(existsSync(tanstackWrapper)).toBe(true); - expect(readFileSync(tanstackWrapper, 'utf-8')).toContain('queryOptions'); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } - }, 30_000); - - test('declaration emit stays portable for a grouped package client (TS2883 net)', async () => { - // Reunite regression: the grouped sugar (`export const { authorize, … } = client`) - // infers method types that reference the runtime's `OperationMethodIdentity`; under - // real declaration emit (their api-sdk builds with tsgo + declarations) every type - // in that inference chain must be nameable from the PACKAGE ROOT, or tsc fails with - // TS2883 "cannot be named … not portable". `--noEmit` does not run this check, so - // this test emits declarations for real (into the temp dir). - const generateClient = await loadGenerateClient(); - const dir = mkdtempSync(join(repoRoot, '.decl-emit-test-')); - try { - const output = join(dir, 'api.ts'); - await generateClient({ - api: fixture, - output, - runtime: 'package', - argsStyle: 'grouped', - generators: ['typescript', 'tanstack-query'], - }); - writeFileSync( - join(dir, 'tsconfig.json'), - JSON.stringify({ - compilerOptions: { - module: 'nodenext', - moduleResolution: 'nodenext', - target: 'es2022', - lib: ['ES2022', 'DOM', 'DOM.AsyncIterable'], - strict: true, - skipLibCheck: true, - declaration: true, - emitDeclarationOnly: true, - outDir: join(dir, 'lib'), - types: [], - }, - include: ['api.ts', 'api.tanstack.ts'], - }) - ); - const result = spawnSync('npx', ['tsc', '-p', dir], { encoding: 'utf-8', cwd: repoRoot }); - expect(result.status, `declaration emit failed:\n${result.stdout}\n${result.stderr}`).toBe(0); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }, 60_000); - - test('CLI --runtime package emits a runtime import', () => { - const tmpDir = mkdtempSync(join(tmpdir(), 'ots-cli-runtime-')); - const output = join(tmpDir, 'cli.ts'); - generate(fixture, output, ['--runtime', 'package']); - const generated = readFileSync(output, 'utf-8'); - expect(generated).toContain("from '@redocly/client-generator'"); - expect(generated).not.toContain('__send'); - rmSync(tmpDir, { recursive: true, force: true }); - }, 30_000); - - test('the package root imports without the codegen stack installed (production install)', () => { - // A production app with a package-runtime client installs @redocly/client-generator - // but not the `typescript` peer. Copy (not symlink) package.json + lib into an empty - // node_modules so nothing outside the package can resolve — the root entry must - // still import and expose the runtime. - const dir = mkdtempSync(join(tmpdir(), 'pkg-root-weight-')); - const staged = join(dir, 'node_modules/@redocly/client-generator'); - cpSync(join(repoRoot, 'packages/client-generator/package.json'), join(staged, 'package.json')); - cpSync(join(repoRoot, 'packages/client-generator/lib'), join(staged, 'lib'), { - recursive: true, - }); - const probe = spawnSync( - 'node', - [ - '-e', - `import('@redocly/client-generator').then((m) => { - if (typeof m.createClient !== 'function') throw new Error('createClient missing'); - if (typeof m.generateClient !== 'function') throw new Error('generateClient missing'); - console.log('root-ok'); - })`, - ], - { cwd: dir, encoding: 'utf-8' } - ); - expect(probe.status, probe.stderr).toBe(0); - expect(probe.stdout).toContain('root-ok'); - rmSync(dir, { recursive: true, force: true }); - }, 60_000); - - test('CLI --runtime rejects an unknown value', () => { - const tmpDir = mkdtempSync(join(tmpdir(), 'ots-cli-runtime-bogus-')); - const output = join(tmpDir, 'cli.ts'); - const result = spawnSync( - 'node', - [cliEntry, 'generate-client', fixture, '--output', output, '--runtime', 'bogus'], - { encoding: 'utf-8', cwd: repoRoot } - ); - expect(result.status).not.toBe(0); - expect(result.stderr).toContain('Invalid values'); - expect(existsSync(output)).toBe(false); - rmSync(tmpDir, { recursive: true, force: true }); - }, 30_000); -}); diff --git a/tests/e2e/generate-client/package-runtime-cjs.test.ts b/tests/e2e/generate-client/package-runtime-cjs.test.ts deleted file mode 100644 index 853b75969e..0000000000 --- a/tests/e2e/generate-client/package-runtime-cjs.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { outdent } from 'outdent'; - -import { cliEntry, repoRoot, tscBin } from './helpers.js'; - -// A CommonJS project (e.g. a NestJS backend) consuming a `runtime: package` client emits -// `require('@redocly/client-generator')`. That resolves through the `default` export -// condition and loads the ESM entry via Node's require(esm) — supported by every Node -// version the package's `engines` allow. - -describe('generate-client package runtime in a CommonJS project', () => { - it('require()s the generated client and completes a call', () => { - const dir = mkdtempSync(join(tmpdir(), 'ots-cjs-')); - writeFileSync(join(dir, 'package.json'), '{"name":"cjs-consumer","private":true}\n'); - mkdirSync(join(dir, 'node_modules/@redocly'), { recursive: true }); - symlinkSync( - join(repoRoot, 'packages/client-generator'), - join(dir, 'node_modules/@redocly/client-generator') - ); - writeFileSync( - join(dir, 'openapi.yaml'), - outdent` - openapi: 3.0.3 - info: { title: test, version: 1.0.0 } - paths: - /foo: - get: - operationId: getFoo - responses: - '200': - description: ok - content: - application/json: - schema: - type: object - properties: - ok: { type: boolean } - ` - ); - const generated = spawnSync( - 'node', - [cliEntry, 'generate-client', 'openapi.yaml', '--output', 'api.ts', '--runtime', 'package'], - { cwd: dir, encoding: 'utf-8' } - ); - expect(generated.status, generated.stderr).toBe(0); - - const tsc = spawnSync( - tscBin, - [ - 'api.ts', - '--module', - 'nodenext', - '--moduleResolution', - 'nodenext', - '--target', - 'es2022', - '--skipLibCheck', - ], - { cwd: dir, encoding: 'utf-8' } - ); - expect(tsc.status, `tsc failed:\n${tsc.stdout}`).toBe(0); - - writeFileSync( - join(dir, 'driver.cjs'), - outdent` - const { configure, getFoo } = require('./api.js'); - configure({ - serverUrl: 'https://api.example.com', - fetch: async () => - new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - }); - getFoo().then((data) => console.log('CJS-OK', JSON.stringify(data))); - ` - ); - const run = spawnSync('node', ['driver.cjs'], { cwd: dir, encoding: 'utf-8' }); - expect(run.status, run.stderr).toBe(0); - expect(run.stdout).toContain('CJS-OK {"ok":true}'); - rmSync(dir, { recursive: true, force: true }); - }, 60_000); -}); diff --git a/tests/e2e/generate-client/package-runtime-consumer/.gitignore b/tests/e2e/generate-client/package-runtime-consumer/.gitignore deleted file mode 100644 index 35d9162694..0000000000 --- a/tests/e2e/generate-client/package-runtime-consumer/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Generated fresh by the owning suite in beforeAll; excluded from the root typecheck. -api.ts diff --git a/tests/e2e/generate-client/package-runtime-consumer/index.ts b/tests/e2e/generate-client/package-runtime-consumer/index.ts deleted file mode 100644 index 24cc14f26b..0000000000 --- a/tests/e2e/generate-client/package-runtime-consumer/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { client, configure_2, createOrder, getOrder, streamEvents, use } from './api.js'; - -async function main(): Promise { - const middlewareIds: string[] = []; - use({ - onRequest: (ctx) => { - middlewareIds.push(ctx.operation.id); - }, - }); - client.auth.bearer('test-token'); - - // Flat sugar: positional path value forwarded under the wire name `order-id`. - 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({ 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(); - - const events: Array<{ seq: number; text?: string }> = []; - for await (const event of streamEvents()) { - events.push({ seq: event.data.seq, text: event.data.text }); - } - - // Compile-time checks: the generated types flow through the package runtime. - const _orderId: string = order.id; - const _createdStatus: string = created.status; - const _collided: string = collided; - void _orderId; - void _createdStatus; - void _collided; - - process.stdout.write( - JSON.stringify({ order, grouped, created, collided, events, middlewareIds }) + '\n' - ); -} - -main().catch((error) => { - process.stderr.write(`UNHANDLED: ${error instanceof Error ? error.message : String(error)}\n`); - process.exit(1); -}); diff --git a/tests/e2e/generate-client/package-runtime-consumer/package.json b/tests/e2e/generate-client/package-runtime-consumer/package.json deleted file mode 100644 index 378af31b08..0000000000 --- a/tests/e2e/generate-client/package-runtime-consumer/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "package-runtime-consumer", - "private": true, - "version": "0.0.0", - "type": "module" -} diff --git a/tests/e2e/generate-client/package-runtime-consumer/server.ts b/tests/e2e/generate-client/package-runtime-consumer/server.ts deleted file mode 100644 index b78d59442e..0000000000 --- a/tests/e2e/generate-client/package-runtime-consumer/server.ts +++ /dev/null @@ -1,78 +0,0 @@ -import * as http from 'node:http'; - -// A hand-written server for the package-runtime consumer: echoes enough request -// detail (auth header, URL) for the test to assert the runtime's routing, and -// serves a short SSE stream that ends cleanly. - -type LogEntry = { method: string; url: string; auth: string | null }; - -const PORT = Number.parseInt(process.env.PKG_SERVER_PORT ?? '3123', 10); - -const requestLog: LogEntry[] = []; - -async function readBody(req: http.IncomingMessage): Promise { - const chunks: Buffer[] = []; - for await (const chunk of req) chunks.push(chunk as Buffer); - return Buffer.concat(chunks).toString('utf-8'); -} - -const server = http.createServer(async (req, res) => { - const method = req.method ?? 'GET'; - const url = req.url ?? ''; - const { pathname } = new URL(url, 'http://localhost'); - - if (pathname === '/__test__/ready') { - res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); - res.end('ready'); - return; - } - if (pathname === '/__test__/log') { - res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); - res.end(JSON.stringify(requestLog)); - return; - } - - const auth = typeof req.headers.authorization === 'string' ? req.headers.authorization : null; - requestLog.push({ method, url, auth }); - - if (method === 'GET' && pathname.startsWith('/orders/')) { - const id = decodeURIComponent(pathname.slice('/orders/'.length)); - res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); - res.end(JSON.stringify({ id, status: 'open' })); - return; - } - if (method === 'POST' && pathname === '/orders') { - const body = JSON.parse(await readBody(req)) as { status: string }; - res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' }); - res.end(JSON.stringify({ id: 'created-1', status: body.status })); - return; - } - if (method === 'GET' && pathname === '/configure-op') { - res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); - res.end(JSON.stringify('ok')); - return; - } - if (method === 'GET' && pathname === '/events') { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }); - res.write('id: 1\ndata: {"seq":1,"text":"a"}\n\n'); - res.write('id: 2\ndata: {"seq":2,"text":"b"}\n\n'); - res.end(); // clean close — the client finishes without reconnecting - return; - } - - res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' }); - res.end(JSON.stringify({ title: 'not found' })); -}); - -// The test process keeps a pooled connection from its readiness probe and fetches -// the log only after generate + tsc + the consumer run (~10s). Outlive that gap so -// the pooled socket is not reset mid-reuse. -server.keepAliveTimeout = 60_000; - -server.listen(PORT, () => { - process.stdout.write(`package-runtime server listening on ${PORT}\n`); -}); diff --git a/tests/e2e/generate-client/package-runtime-consumer/tsconfig.json b/tests/e2e/generate-client/package-runtime-consumer/tsconfig.json deleted file mode 100644 index 9e758c589a..0000000000 --- a/tests/e2e/generate-client/package-runtime-consumer/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "module": "nodenext", - "moduleResolution": "nodenext", - "target": "es2022", - "lib": ["ES2022", "DOM", "DOM.AsyncIterable"], - "strict": true, - "noUnusedLocals": true, - "noEmit": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "types": ["node"] - }, - "include": ["./**/*.ts"] -} diff --git a/tests/e2e/generate-client/pagination-consumer/index-package.ts b/tests/e2e/generate-client/pagination-consumer/index-package.ts deleted file mode 100644 index bee2fe501f..0000000000 --- a/tests/e2e/generate-client/pagination-consumer/index-package.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { listOrders } from './api-package.js'; - -// The package-mode arm: the generated file imports the runtime from -// `@redocly/client-generator`, so `.pages()`/`.items()` ship from the INSTALLED -// 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({ query: { limit: 2 } })) { - ids.push(order.id); - } - - process.stdout.write(JSON.stringify({ ids }) + '\n'); -} - -main().catch((error) => { - process.stderr.write(`UNHANDLED: ${error instanceof Error ? error.message : String(error)}\n`); - process.exit(1); -}); diff --git a/tests/e2e/generate-client/pagination.test.ts b/tests/e2e/generate-client/pagination.test.ts index 267ee28248..a98aa6a923 100644 --- a/tests/e2e/generate-client/pagination.test.ts +++ b/tests/e2e/generate-client/pagination.test.ts @@ -18,7 +18,6 @@ const fixture = join(__dirname, 'fixtures/pagination.yaml'); const consumerDir = join(__dirname, 'pagination-consumer'); const apiFile = join(consumerDir, 'api.ts'); const apiOffsetFile = join(consumerDir, 'api-offset.ts'); -const apiPackageFile = join(consumerDir, 'api-package.ts'); const serverScript = join(consumerDir, 'server.ts'); const SERVER_PORT = 3131; @@ -57,7 +56,7 @@ describe('generate-client pagination consumer', () => { let serverProcess: ChildProcess | undefined; beforeAll(async () => { - for (const file of [apiFile, apiOffsetFile, apiPackageFile]) { + for (const file of [apiFile, apiOffsetFile]) { if (existsSync(file)) rmSync(file, { force: true }); } @@ -86,8 +85,6 @@ describe('generate-client pagination consumer', () => { output: apiOffsetFile, pagination: { style: 'offset', offsetParam: 'offset', limitParam: 'limit', items: '/items' }, }); - // Package arm: same extension-driven spec, runtime imported from the package. - await generateClient({ api: fixture, output: apiPackageFile, runtime: 'package' }); const api = readFileSync(apiFile, 'utf-8'); // The extension is normalized into the descriptor (param unified, stable key order). @@ -127,15 +124,6 @@ describe('generate-client pagination consumer', () => { expect(offset).toContain( 'getOrder: { id: "getOrder", method: "GET", path: "/orders/{orderId}", params: [{ name: "orderId", in: "path" }] }' ); - - const pkg = readFileSync(apiPackageFile, 'utf-8'); - // Package mode: no embedded runtime — pagination arrives via the import. - expect(pkg).toContain("from '@redocly/client-generator'"); - expect(pkg).not.toContain('// ─── Embedded runtime'); - expect(pkg).toContain( - 'pagination: { style: "cursor", param: "cursor", limitParam: "limit", nextCursor: "/nextCursor", items: "/orders" }' - ); - expect(pkg).toContain('export const { listOrders, listMenuItems, getOrder } = client;'); }, 60_000); test('typecheck gate: all three generated clients + consumer scripts, strict', () => { @@ -226,18 +214,4 @@ describe('generate-client pagination consumer', () => { expect(pageRequests.length).toBeGreaterThanOrEqual(1); expect(pageRequests.length).toBeLessThanOrEqual(2); }, 60_000); - - test('package-mode arm: .items() runs on the runtime imported from the package', async () => { - await resetLog(); - const { stdout } = runConsumer('index-package.ts'); - const parsed = JSON.parse(stdout.trim()) as { ids: string[] }; - expect(parsed.ids).toEqual(['o-1', 'o-2', 'o-3', 'o-4', 'o-5']); - - const log = await fetchLog(); - expect(log.map((e) => e.url)).toEqual([ - '/orders?limit=2', - '/orders?limit=2&cursor=c2', - '/orders?limit=2&cursor=c3', - ]); - }, 60_000); }); diff --git a/tests/e2e/generate-client/per-instance-auth.test.ts b/tests/e2e/generate-client/per-instance-auth.test.ts index 72d3e5ab62..16ffad37f0 100644 --- a/tests/e2e/generate-client/per-instance-auth.test.ts +++ b/tests/e2e/generate-client/per-instance-auth.test.ts @@ -30,8 +30,7 @@ const SPEC = outdent` `; const DRIVER = outdent` - import { createClient } from '@redocly/client-generator'; - import { OPERATIONS, type Ops } from './client.js'; + import { createClient, OPERATIONS, type Ops } from './client.js'; const calls: (string | null)[] = []; const fakeFetch = (async (_url: string, init?: RequestInit) => { @@ -55,12 +54,10 @@ const DRIVER = outdent` describe('per-instance auth (createClient config.auth)', () => { it('two instances send different credentials; a no-auth instance sends none', () => { - // The temp dir lives INSIDE the repo so the driver's import of - // `@redocly/client-generator` resolves through the workspace node_modules symlink. const dir = mkdtempSync(join(__dirname, '.tmp-perinstance-')); try { writeFileSync(join(dir, 'openapi.yaml'), SPEC, 'utf-8'); - generateInto(dir, join(dir, 'openapi.yaml'), ['--runtime', 'package']); + generateInto(dir, join(dir, 'openapi.yaml')); writeFileSync(join(dir, 'driver.ts'), DRIVER, 'utf-8'); const run = spawnSync(tsxBin, [join(dir, 'driver.ts')], { encoding: 'utf-8', cwd: dir }); diff --git a/tests/e2e/generate-client/redocly-config.test.ts b/tests/e2e/generate-client/redocly-config.test.ts index ec62077ed1..2cfae277b2 100644 --- a/tests/e2e/generate-client/redocly-config.test.ts +++ b/tests/e2e/generate-client/redocly-config.test.ts @@ -194,17 +194,16 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' runtime: package', + ' argsStyle: flat', ].join('\n') + '\n' ); const res = run(dir, ['cafe']); expect(res.status, res.stderr).toBe(0); const entry = readFileSync(join(dir, 'out.ts'), 'utf-8'); // The per-api block applied… - expect(entry).toContain("from '@redocly/client-generator'"); + expect(entry).toContain('argsStyle: "flat"'); // …and the top-level fields did NOT leak in: default throw mode, no zod module. - // (`\b` keeps the throw-mode `EnvelopeResult<` from matching.) - expect(entry).not.toMatch(/\bResult { rmSync(dir, { recursive: true, force: true }); }, 60_000); - it('a `client.runtime: package` config block reaches the writer', () => { - const dir = project( - [ - 'apis:', - ' cafe:', - ' root: ./openapi.yaml', - ' clientOutput: ./out.ts', - ' client:', - ' generators: [typescript]', - ' runtime: package', - ].join('\n') + '\n' - ); - const res = run(dir, ['cafe']); - expect(res.status, res.stderr).toBe(0); - const out = readFileSync(join(dir, 'out.ts'), 'utf-8'); - expect(out).toContain("from '@redocly/client-generator'"); - expect(out).not.toContain('__send'); - rmSync(dir, { recursive: true, force: true }); - }, 60_000); - - it('a per-api block drives split output, extra generators, and the package runtime', () => { + it('a per-api block drives split output and extra generators', () => { const dir = project( [ 'apis:', @@ -488,7 +467,6 @@ describe('generate-client redocly.yaml config', () => { ' client:', ' generators: [typescript, zod]', ' outputMode: split', - ' runtime: package', ].join('\n') + '\n' ); const res = run(dir, ['realm']); @@ -496,9 +474,6 @@ describe('generate-client redocly.yaml config', () => { expect(existsSync(join(dir, 'out/client.ts'))).toBe(true); expect(existsSync(join(dir, 'out/client.schemas.ts'))).toBe(true); // split layout expect(existsSync(join(dir, 'out/client.zod.ts'))).toBe(true); - expect(readFileSync(join(dir, 'out/client.ts'), 'utf-8')).toContain( - "from '@redocly/client-generator'" // package runtime - ); rmSync(dir, { recursive: true, force: true }); }, 60_000); diff --git a/tests/e2e/generate-client/tanstack-query.test.ts b/tests/e2e/generate-client/tanstack-query.test.ts index e489861087..492012aa12 100644 --- a/tests/e2e/generate-client/tanstack-query.test.ts +++ b/tests/e2e/generate-client/tanstack-query.test.ts @@ -11,9 +11,6 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); // devDependency); map it explicitly so tsc resolves the generated module's // `import { queryOptions } from "@tanstack/react-query"` from the temp dir. const tanstackPath = join(repoRoot, 'node_modules/@tanstack/react-query'); -// The package-runtime case resolves the sdk's `@redocly/client-generator` import against the -// BUILT package types (the temp project lives outside the workspace, so no node_modules walk). -const generatorTypes = join(repoRoot, 'packages/client-generator/lib/index.d.ts'); describe('generate-client tanstack-query generator', () => { it('emits a *.tanstack.ts module that strict-tsc-checks against real @tanstack/react-query and composes with the sdk', () => { @@ -95,81 +92,6 @@ describe('generate-client tanstack-query generator', () => { rmSync(dir, { recursive: true, force: true }); }, 60_000); - it('--runtime package: the tanstack wrapper composes with the package-runtime sdk and strict-tsc-checks', () => { - const dir = mkdtempSync(join(tmpdir(), 'ots-tanstack-pkg-')); - const out = join(dir, 'client.ts'); - const tanstackOut = join(dir, 'client.tanstack.ts'); - - generate(join(__dirname, 'fixtures', 'base.yaml'), out, [ - '--runtime', - 'package', - '--generator', - 'typescript', - '--generator', - 'tanstack-query', - ]); - - // The sdk entry imports the runtime instead of embedding it; the wrapper is unchanged - // in shape — it consumes the same free functions + Variables surface. - const sdkSource = readFileSync(out, 'utf-8'); - expect(sdkSource).toContain("from '@redocly/client-generator'"); - expect(sdkSource).not.toContain('__send'); - const source = readFileSync(tanstackOut, 'utf-8'); - expect(source).toContain('import { queryOptions } from "@tanstack/react-query"'); - expect(source).toContain('export const getPetByIdOptions'); - expect(source).toContain('export const createPetMutation'); - - // Same composition consumer as the inline case — the runtime choice must be - // invisible to wrapper consumers. - writeFileSync( - join(dir, 'check.ts'), - [ - "import { useMutation, useQuery } from '@tanstack/react-query';", - "import { createPetMutation, getPetByIdOptions, listPetsOptions } from './client.tanstack.js';", - 'export function useGetPet(id: number) {', - ' return useQuery(getPetByIdOptions({ path: { id } }));', - '}', - 'export function useListPets() {', - " return useQuery(listPetsOptions({ query: { filter: { name: 'rex' } } }));", - '}', - 'export function useCreatePet() {', - ' return useMutation(createPetMutation());', - '}', - '', - ].join('\n'), - 'utf-8' - ); - - // strict-tsc gate: the wrapper + the package-runtime sdk (resolved against the BUILT - // @redocly/client-generator types) + the composition consumer, all in one project. - writeFileSync( - join(dir, 'tsconfig.json'), - JSON.stringify({ - compilerOptions: { - module: 'nodenext', - moduleResolution: 'nodenext', - target: 'es2022', - lib: ['ES2022', 'DOM'], - strict: true, - noEmit: true, - skipLibCheck: true, - types: [], - paths: { - '@tanstack/react-query': [tanstackPath], - '@redocly/client-generator': [generatorTypes], - }, - }, - include: ['client.ts', 'client.tanstack.ts', 'check.ts'], - }), - 'utf-8' - ); - - const tsc = spawnSync(tscBin, ['--noEmit', '-p', dir], { encoding: 'utf-8', cwd: repoRoot }); - expect(tsc.status, `tsc failed:\n${tsc.stdout}\n${tsc.stderr}`).toBe(0); - - rmSync(dir, { recursive: true, force: true }); - }, 60_000); - it('the tanstack-query-vue variant swaps only the import specifier to @tanstack/vue-query', () => { const dir = mkdtempSync(join(tmpdir(), 'ots-tanstack-vue-')); const out = join(dir, 'client.ts'); From 1daa39f44c9f4d218fab4a48fa0c0a9920f125cd Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 23:35:45 +0300 Subject: [PATCH 29/35] refactor: move the runtime sources into their generator folders and define the setup and pagination contracts at package level --- packages/client-generator/README.md | 2 +- .../eject-assets/skills/go-generator/SKILL.md | 2 +- .../skills/php-generator/SKILL.md | 2 +- .../skills/python-generator/SKILL.md | 2 +- .../scripts/generate-runtime-sources.mjs | 111 +++++++++++++-- .../__tests__/runtime-sources.test.ts | 51 ++++++- .../__tests__/generator-skills.test.ts | 2 +- .../__tests__/go-runtime-embed.test.ts | 2 +- .../__tests__/php-runtime-embed.test.ts | 2 +- .../__tests__/python-runtime-embed.test.ts | 2 +- .../__tests__/runtime-embed-freshness.test.ts | 6 +- .../src/generators/cli/docs.ts | 2 +- .../src/generators/cli/index.ts | 2 +- .../src/generators/cli/render.ts | 2 +- .../cli}/runtime/__tests__/cli.test.ts | 0 .../src/{ => generators/cli}/runtime/cli.ts | 0 .../src/generators/go/AGENTS.md | 4 +- .../go => src/generators/go/runtime}/go.mod | 0 .../generators/go/runtime}/runtime.go | 0 .../src/generators/php/AGENTS.md | 4 +- .../generators/php/runtime}/runtime.php | 0 .../src/generators/python/AGENTS.md | 4 +- .../generators/python/runtime}/_auth.py | 0 .../generators/python/runtime}/_decode.py | 0 .../generators/python/runtime}/_errors.py | 0 .../generators/python/runtime}/_multipart.py | 0 .../generators/python/runtime}/_paginate.py | 0 .../generators/python/runtime}/_send.py | 0 .../generators/python/runtime}/_sse.py | 0 .../generators/python/runtime}/_url.py | 0 .../src/generators/tanstack-query/render.ts | 7 +- .../generators/typescript/response-headers.ts | 2 +- .../runtime/__tests__/auth.test.ts | 0 .../runtime/__tests__/create-client.test.ts | 0 .../runtime/__tests__/errors.test.ts | 0 .../runtime/__tests__/index.test.ts | 2 +- .../runtime/__tests__/multipart.test.ts | 0 .../runtime/__tests__/paginate.test.ts | 0 .../runtime/__tests__/parse.test.ts | 0 .../runtime/__tests__/retry.test.ts | 0 .../runtime/__tests__/send.test.ts | 0 .../typescript}/runtime/__tests__/sse.test.ts | 0 .../runtime/__tests__/types.test.ts | 0 .../typescript}/runtime/__tests__/url.test.ts | 0 .../typescript}/runtime/auth.ts | 0 .../typescript}/runtime/create-client.ts | 0 .../typescript}/runtime/errors.ts | 0 .../typescript}/runtime/index.ts | 0 .../typescript}/runtime/multipart.ts | 0 .../typescript}/runtime/paginate.ts | 0 .../typescript}/runtime/parse.ts | 0 .../typescript}/runtime/retry.ts | 0 .../typescript}/runtime/send.ts | 0 .../typescript}/runtime/setup.ts | 0 .../typescript}/runtime/sse.ts | 0 .../typescript}/runtime/types.ts | 130 ++++-------------- .../typescript}/runtime/url.ts | 0 packages/client-generator/src/index.ts | 2 +- packages/client-generator/src/pagination.ts | 37 ++++- .../client-generator/src/runtime-contract.ts | 87 ++++++++++-- 60 files changed, 306 insertions(+), 163 deletions(-) rename packages/client-generator/src/{ => generators/cli}/runtime/__tests__/cli.test.ts (100%) rename packages/client-generator/src/{ => generators/cli}/runtime/cli.ts (100%) rename packages/client-generator/{runtime/go => src/generators/go/runtime}/go.mod (100%) rename packages/client-generator/{runtime/go => src/generators/go/runtime}/runtime.go (100%) rename packages/client-generator/{runtime/php => src/generators/php/runtime}/runtime.php (100%) rename packages/client-generator/{runtime/python => src/generators/python/runtime}/_auth.py (100%) rename packages/client-generator/{runtime/python => src/generators/python/runtime}/_decode.py (100%) rename packages/client-generator/{runtime/python => src/generators/python/runtime}/_errors.py (100%) rename packages/client-generator/{runtime/python => src/generators/python/runtime}/_multipart.py (100%) rename packages/client-generator/{runtime/python => src/generators/python/runtime}/_paginate.py (100%) rename packages/client-generator/{runtime/python => src/generators/python/runtime}/_send.py (100%) rename packages/client-generator/{runtime/python => src/generators/python/runtime}/_sse.py (100%) rename packages/client-generator/{runtime/python => src/generators/python/runtime}/_url.py (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/__tests__/auth.test.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/__tests__/create-client.test.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/__tests__/errors.test.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/__tests__/index.test.ts (98%) rename packages/client-generator/src/{ => generators/typescript}/runtime/__tests__/multipart.test.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/__tests__/paginate.test.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/__tests__/parse.test.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/__tests__/retry.test.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/__tests__/send.test.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/__tests__/sse.test.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/__tests__/types.test.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/__tests__/url.test.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/auth.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/create-client.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/errors.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/index.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/multipart.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/paginate.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/parse.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/retry.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/send.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/setup.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/sse.ts (100%) rename packages/client-generator/src/{ => generators/typescript}/runtime/types.ts (76%) rename packages/client-generator/src/{ => generators/typescript}/runtime/url.ts (100%) diff --git a/packages/client-generator/README.md b/packages/client-generator/README.md index 0a08789c3d..ceb6f6f134 100644 --- a/packages/client-generator/README.md +++ b/packages/client-generator/README.md @@ -177,4 +177,4 @@ npm run unit # unit tests (this package is held at 100% cover VITEST_SUITE=e2e npx vitest run tests/e2e/generate-client/ # behavioral e2e ``` -The client runtime lives in `src/runtime/` (real, unit-testable modules that generation embeds), the structural emitters in `src/emitters/`, the IR in `src/intermediate-representation/`, and the generators in `src/generators/`. +Each generator that embeds a runtime keeps its sources in its own folder (`src/generators//runtime/` — real, unit-testable modules that generation embeds), the IR lives in `src/intermediate-representation/`, and the generators in `src/generators/`. diff --git a/packages/client-generator/eject-assets/skills/go-generator/SKILL.md b/packages/client-generator/eject-assets/skills/go-generator/SKILL.md index 175fe3ca5e..6bcd2becf2 100644 --- a/packages/client-generator/eject-assets/skills/go-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/go-generator/SKILL.md @@ -72,7 +72,7 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies. inside a doc comment is `//` — never `// ` with a trailing space. A change here is verified by the `gofmt -l` bar in the unit suite, at cafe AND large-description scale. -- The runtime is hand-written in `runtime/go/runtime.go` (gofmt-clean, `go vet`-clean) +- The runtime is hand-written in `runtime/runtime.go` in this folder (gofmt-clean, `go vet`-clean) and embedded at prepare time. - Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. diff --git a/packages/client-generator/eject-assets/skills/php-generator/SKILL.md b/packages/client-generator/eject-assets/skills/php-generator/SKILL.md index 1508aba0ed..6ffbc4cfd5 100644 --- a/packages/client-generator/eject-assets/skills/php-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/php-generator/SKILL.md @@ -74,7 +74,7 @@ $idempotencyKey` on mutating methods. - **Parity surface:** auth, retries with `Retry-After` + jittered backoff, per-attempt curl timeouts, middleware callables, pagination (`Pages()` / `Items()` as `\Generator`s), SSE (`iterSse` over a curl_multi pump), multipart. -- The runtime is hand-written in `runtime/php/runtime.php` (`php -l`-clean) and embedded +- The runtime is hand-written in `runtime/runtime.php` in this folder (`php -l`-clean) and embedded at prepare time. `curl_close` is never called (deprecated since PHP 8.5, no-op since 8.0). - Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. diff --git a/packages/client-generator/eject-assets/skills/python-generator/SKILL.md b/packages/client-generator/eject-assets/skills/python-generator/SKILL.md index 5173a6b44f..9c681832b4 100644 --- a/packages/client-generator/eject-assets/skills/python-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/python-generator/SKILL.md @@ -85,7 +85,7 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a - **Parity surface:** auth (bearer/basic/apiKey), retries with `Retry-After` + jittered backoff, timeouts, idempotency keys, middleware, pagination (`_pages()` / `_items()` + `aiter` mirrors), SSE (`iter_sse`/`aiter_sse`), multipart. -- The runtime is hand-written in `runtime/python/*.py` and embedded as strings at prepare +- The runtime is hand-written in `runtime/*.py` in this folder and embedded as strings at prepare time — generator code never builds runtime logic from templates. - Authored ONLY with the neutral toolkit (`Printer`, naming, schema, pagination helpers) — the dogfooding guard fails otherwise. diff --git a/packages/client-generator/scripts/generate-runtime-sources.mjs b/packages/client-generator/scripts/generate-runtime-sources.mjs index 7dba49252a..c6914ed177 100644 --- a/packages/client-generator/scripts/generate-runtime-sources.mjs +++ b/packages/client-generator/scripts/generate-runtime-sources.mjs @@ -3,10 +3,17 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; -// Snapshot src/runtime/*.ts source text into a tracked TS module so the inline assembler -// can embed the real runtime (a readFileSync asset would not survive the CLI's esbuild -// bundling). Order is the assembler's fixed dependency order; the barrel (index.ts) is -// not embedded — the assembler emits its own local createClient wiring. +// Snapshot the runtime sources (src/generators/typescript/runtime/*.ts and the cli +// engine at src/generators/cli/runtime/cli.ts) into a tracked TS module so the inline +// assembler can embed the real runtime (a readFileSync asset would not survive the CLI's +// esbuild bundling). Order is the assembler's fixed dependency order; the barrel +// (index.ts) is not embedded — the assembler emits its own local createClient wiring. +// +// The contract types the runtime imports from the package level (ADR-0022: the setup +// contract in src/runtime-contract.ts, `PaginationSpec` beside its resolver in +// src/pagination.ts) are spliced back into the embedded `types.ts` here, replacing the +// re-export statements — the embedded module stays self-contained with one definition +// in the source tree. const MODULES = [ 'types', 'errors', @@ -24,9 +31,89 @@ const MODULES = [ ]; const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); -const runtimeDir = join(pkgRoot, 'src', 'runtime'); +const runtimeDir = join(pkgRoot, 'src', 'generators', 'typescript', 'runtime'); const outFile = join(pkgRoot, 'src', 'emitters', 'runtime-sources.ts'); +// The package-level modules whose type declarations the embed splices back in, keyed by +// the specifier the runtime imports them with. +const CONTRACT_MODULES = { + '../../../runtime-contract.js': join(pkgRoot, 'src', 'runtime-contract.ts'), + '../../../pagination.js': join(pkgRoot, 'src', 'pagination.ts'), +}; + +/** The declaration's start including its own doc comment, excluding detached trivia. */ +function declStartWithDocs(source, declaration) { + const ranges = ts.getLeadingCommentRanges(source, declaration.getFullStart()) ?? []; + let start = declaration.getStart(); + for (let index = ranges.length - 1; index >= 0; index--) { + if (/\n\s*\n/.test(source.slice(ranges[index].end, start))) break; + start = ranges[index].pos; + } + return start; +} + +/** The named type declarations of a contract module, verbatim and in source order. */ +function contractDeclarationsText(modulePath, names) { + const source = readFileSync(modulePath, 'utf-8'); + const file = ts.createSourceFile('__contract.ts', source, ts.ScriptTarget.Latest, true); + const wanted = new Set(names); + const parts = []; + for (const statement of file.statements) { + if (ts.isTypeAliasDeclaration(statement) && wanted.has(statement.name.text)) { + parts.push(source.slice(declStartWithDocs(source, statement), statement.end)); + wanted.delete(statement.name.text); + } + } + if (wanted.size > 0) { + throw new Error(`contract splice: ${[...wanted].join(', ')} not found in ${modulePath}`); + } + return parts.join('\n\n'); +} + +/** + * Replace the runtime module's contract imports/re-exports with the definitions they + * point at, so every downstream use (full source, stripped embed, declared names) sees + * one self-contained module. + */ +function spliceContracts(source) { + const file = ts.createSourceFile('__splice.ts', source, ts.ScriptTarget.Latest, true); + const edits = []; + for (const statement of file.statements) { + if (ts.isImportDeclaration(statement) && CONTRACT_MODULES[statement.moduleSpecifier.text]) { + // Delete the import line and its trailing newlines only — the module's header + // comment is this statement's leading trivia and must survive. + let end = statement.end; + while (source[end] === '\n') end++; + edits.push({ start: statement.getStart(), end, text: '' }); + } else if ( + ts.isExportDeclaration(statement) && + statement.moduleSpecifier !== undefined && + CONTRACT_MODULES[statement.moduleSpecifier.text] + ) { + const names = statement.exportClause.elements.map((element) => element.name.text); + const block = contractDeclarationsText( + CONTRACT_MODULES[statement.moduleSpecifier.text], + names + ); + edits.push({ start: statement.getFullStart(), end: statement.end, text: `\n\n${block}` }); + } + } + let spliced = source; + for (const edit of edits.reverse()) { + spliced = spliced.slice(0, edit.start) + edit.text + spliced.slice(edit.end); + } + return spliced; +} + +/** A runtime module's embeddable source: the cli engine lives in the cli generator. */ +function runtimeSource(name) { + const path = + name === 'cli' + ? join(pkgRoot, 'src', 'generators', 'cli', 'runtime', 'cli.ts') + : join(runtimeDir, `${name}.ts`); + return spliceContracts(readFileSync(path, 'utf-8')); +} + // Emit the literal exactly as oxfmt (singleQuote: true) would format it, so that // compile → format is a no-op: prefer single quotes unless that needs more escapes. function toStringLiteral(source) { @@ -41,7 +128,7 @@ function toStringLiteral(source) { } const entries = MODULES.map((name) => { - const source = readFileSync(join(runtimeDir, `${name}.ts`), 'utf-8'); + const source = runtimeSource(name); const line = ` '${name}.ts': ${toStringLiteral(source)},`; // oxfmt (printWidth: 100) breaks an over-width property onto a continuation line. return line.length <= 100 ? line : ` '${name}.ts':\n ${toStringLiteral(source)},`; @@ -53,7 +140,7 @@ const entries = MODULES.map((name) => { function declaredNames() { const names = new Set(); for (const name of MODULES) { - const source = readFileSync(join(runtimeDir, `${name}.ts`), 'utf-8'); + const source = runtimeSource(name); const file = ts.createSourceFile(`${name}.ts`, source, ts.ScriptTarget.Latest, false); for (const statement of file.statements) { if ( @@ -75,7 +162,7 @@ function declaredNames() { return [...names].sort(); } -// The Python runtime (runtime/python/*.py) embeds the same way: hand-authored +// The Python runtime (src/generators/python/runtime/*.py) embeds the same way: hand-authored // once, stitched into every generated Python client by the python generator. const PYTHON_MODULES = [ '_errors', @@ -87,7 +174,7 @@ const PYTHON_MODULES = [ '_sse', '_multipart', ]; -const pythonDir = join(pkgRoot, 'runtime', 'python'); +const pythonDir = join(pkgRoot, 'src', 'generators', 'python', 'runtime'); 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'); @@ -108,7 +195,7 @@ writeFileSync( ); // The Go runtime embeds the same way (a single stdlib-only module). -const goDir = join(pkgRoot, 'runtime', 'go'); +const goDir = join(pkgRoot, 'src', 'generators', 'go', 'runtime'); const goOut = join(pkgRoot, 'src', 'emitters', 'go-runtime-sources.ts'); const goSource = readFileSync(join(goDir, 'runtime.go'), 'utf-8'); writeFileSync( @@ -122,7 +209,7 @@ writeFileSync( ); // The PHP runtime embeds the same way (a single curl-only module). -const phpDir = join(pkgRoot, 'runtime', 'php'); +const phpDir = join(pkgRoot, 'src', 'generators', 'php', 'runtime'); const phpOut = join(pkgRoot, 'src', 'emitters', 'php-runtime-sources.ts'); const phpSource = readFileSync(join(phpDir, 'runtime.php'), 'utf-8'); writeFileSync( @@ -174,7 +261,7 @@ function stripModule(name, source) { } const strippedEntries = MODULES.map((name) => { - const source = readFileSync(join(runtimeDir, `${name}.ts`), 'utf-8'); + const source = runtimeSource(name); const stripped = stripModule(`${name}.ts`, source); const line = ` '${name}.ts': ${toStringLiteral(stripped)},`; return line.length <= 100 ? line : ` '${name}.ts':\n ${toStringLiteral(stripped)},`; diff --git a/packages/client-generator/src/emitters/__tests__/runtime-sources.test.ts b/packages/client-generator/src/emitters/__tests__/runtime-sources.test.ts index 8c304cd504..ea6cf6ec5c 100644 --- a/packages/client-generator/src/emitters/__tests__/runtime-sources.test.ts +++ b/packages/client-generator/src/emitters/__tests__/runtime-sources.test.ts @@ -4,18 +4,55 @@ import { fileURLToPath } from 'node:url'; import { RUNTIME_SOURCES } from '../runtime-sources.js'; -const runtimeDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'runtime'); +const pkgSrc = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const runtimeDir = join(pkgSrc, 'generators', 'typescript', 'runtime'); +const STALE = + 'emitters/runtime-sources.ts is stale — run `npm run prepare -w @redocly/client-generator`'; + +/** A source region between two anchors (end exclusive), for the spliced-contract checks. */ +function between(source: string, from: string, to: string): string { + const start = source.indexOf(from); + const end = source.indexOf(to, start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return source.slice(start, end).trimEnd(); +} describe('runtime-sources', () => { - it('the generated snapshot matches src/runtime (every module except the barrel)', () => { + it('the generated snapshot matches the runtime sources (every module except the barrel)', () => { + // `types.ts` is spliced (see the splice test below) and `cli.ts` lives with the cli + // generator; every other module is embedded byte-for-byte. const expected = Object.fromEntries( readdirSync(runtimeDir) - .filter((name) => name.endsWith('.ts') && name !== 'index.ts') + .filter((name) => name.endsWith('.ts') && name !== 'index.ts' && name !== 'types.ts') .map((name) => [name, readFileSync(join(runtimeDir, name), 'utf-8')]) ); - expect( - { ...RUNTIME_SOURCES }, - 'emitters/runtime-sources.ts is stale — run `npm run prepare -w @redocly/client-generator`' - ).toEqual(expected); + expected['cli.ts'] = readFileSync( + join(pkgSrc, 'generators', 'cli', 'runtime', 'cli.ts'), + 'utf-8' + ); + const { 'types.ts': _types, ...rest } = RUNTIME_SOURCES; + expect({ ...rest }, STALE).toEqual(expected); + }); + + it('the embedded types.ts splices the package-level contract types back in', () => { + const embedded: string = RUNTIME_SOURCES['types.ts']; + // Self-contained: no import or re-export may survive into the embeddable source. + expect(embedded).not.toContain("from '../../../runtime-contract.js'"); + expect(embedded).not.toContain("from '../../../pagination.js'"); + // The definitions arrive verbatim from their package-level owners. + const pagination = readFileSync(join(pkgSrc, 'pagination.ts'), 'utf-8'); + expect(embedded, STALE).toContain( + between(pagination, '/**\n * How to auto-iterate', '\n\n/** The pagination styles') + ); + const contract = readFileSync(join(pkgSrc, 'runtime-contract.ts'), 'utf-8'); + expect(embedded, STALE).toContain( + between(contract, '/** Backoff shape:', '\n\n/**\n * The spec-independent subset') + ); + // And the module's own tail is still the source file's, byte-for-byte. + const source = readFileSync(join(runtimeDir, 'types.ts'), 'utf-8'); + expect(embedded, STALE).toContain( + between(source, '/** Client configuration:', '\n\n/** Response readers') + ); }); }); diff --git a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts index 38304f32d6..3253498dbd 100644 --- a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts +++ b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts @@ -36,7 +36,7 @@ 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}/`); + expect(readFileSync(skillPath, 'utf-8')).toContain('runtime/'); }); it('ships without repo-only references — the user has no prepare script or vitest', () => { diff --git a/packages/client-generator/src/generators/__tests__/go-runtime-embed.test.ts b/packages/client-generator/src/generators/__tests__/go-runtime-embed.test.ts index 875f57ddd4..9b59ec6acc 100644 --- a/packages/client-generator/src/generators/__tests__/go-runtime-embed.test.ts +++ b/packages/client-generator/src/generators/__tests__/go-runtime-embed.test.ts @@ -27,7 +27,7 @@ describe('GO_RUNTIME_SOURCE (the embedded Go runtime)', () => { it.skipIf(!hasGo)('the runtime module passes go vet', () => { const result = spawnSync('go', ['vet', './...'], { - cwd: join(pkgRoot, 'runtime', 'go'), + cwd: join(pkgRoot, 'src', 'generators', 'go', 'runtime'), encoding: 'utf-8', }); expect(result.status, result.stderr).toBe(0); diff --git a/packages/client-generator/src/generators/__tests__/php-runtime-embed.test.ts b/packages/client-generator/src/generators/__tests__/php-runtime-embed.test.ts index 87a5d5cd51..b3f655d03d 100644 --- a/packages/client-generator/src/generators/__tests__/php-runtime-embed.test.ts +++ b/packages/client-generator/src/generators/__tests__/php-runtime-embed.test.ts @@ -27,7 +27,7 @@ describe('PHP_RUNTIME_SOURCE (the embedded PHP runtime)', () => { it.skipIf(!hasPhp)('the runtime module passes php -l', () => { const result = spawnSync('php', ['-l', 'runtime.php'], { - cwd: join(pkgRoot, 'runtime', 'php'), + cwd: join(pkgRoot, 'src', 'generators', 'php', 'runtime'), encoding: 'utf-8', }); expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); diff --git a/packages/client-generator/src/generators/__tests__/python-runtime-embed.test.ts b/packages/client-generator/src/generators/__tests__/python-runtime-embed.test.ts index 9dbd145175..6dea8d127a 100644 --- a/packages/client-generator/src/generators/__tests__/python-runtime-embed.test.ts +++ b/packages/client-generator/src/generators/__tests__/python-runtime-embed.test.ts @@ -21,7 +21,7 @@ describe('PYTHON_RUNTIME_SOURCES (the embedded Python runtime)', () => { for (const name of Object.keys(PYTHON_RUNTIME_SOURCES)) { const result = spawnSync( 'python3', - ['-m', 'py_compile', join(pkgRoot, 'runtime', 'python', name)], + ['-m', 'py_compile', join(pkgRoot, 'src', 'generators', 'python', 'runtime', name)], { encoding: 'utf-8', } diff --git a/packages/client-generator/src/generators/__tests__/runtime-embed-freshness.test.ts b/packages/client-generator/src/generators/__tests__/runtime-embed-freshness.test.ts index 2966209819..4a29ba4e65 100644 --- a/packages/client-generator/src/generators/__tests__/runtime-embed-freshness.test.ts +++ b/packages/client-generator/src/generators/__tests__/runtime-embed-freshness.test.ts @@ -17,17 +17,17 @@ const STALE = 'stale embed — run `npm run prepare -w @redocly/client-generator describe('embedded runtimes match their source files', () => { it('go', () => { - const source = readFileSync(join(pkgRoot, 'runtime/go/runtime.go'), 'utf-8'); + const source = readFileSync(join(pkgRoot, 'src/generators/go/runtime/runtime.go'), 'utf-8'); expect(GO_RUNTIME_SOURCE, STALE).toBe(source); }); it('php', () => { - const source = readFileSync(join(pkgRoot, 'runtime/php/runtime.php'), 'utf-8'); + const source = readFileSync(join(pkgRoot, 'src/generators/php/runtime/runtime.php'), 'utf-8'); expect(PHP_RUNTIME_SOURCE, STALE).toBe(source); }); it('python — every module, and no module missing from the snapshot', () => { - const dir = join(pkgRoot, 'runtime', 'python'); + const dir = join(pkgRoot, 'src', 'generators', 'python', 'runtime'); const onDisk = readdirSync(dir).filter((name) => name.endsWith('.py')); expect(Object.keys(PYTHON_RUNTIME_SOURCES).sort(), STALE).toEqual(onDisk.sort()); const embedded: Record = PYTHON_RUNTIME_SOURCES; diff --git a/packages/client-generator/src/generators/cli/docs.ts b/packages/client-generator/src/generators/cli/docs.ts index 23c6a3ee2b..c77f8a007c 100644 --- a/packages/client-generator/src/generators/cli/docs.ts +++ b/packages/client-generator/src/generators/cli/docs.ts @@ -4,7 +4,7 @@ // the tool the first time either side changed. import { Printer } from '../../authoring/printer.js'; -import { constantCase, groupSlug, type CliCommand, type CliFlag } from '../../runtime/cli.js'; +import { constantCase, groupSlug, type CliCommand, type CliFlag } from './runtime/cli.js'; export type CliDocsOptions = { /** Page heading. */ diff --git a/packages/client-generator/src/generators/cli/index.ts b/packages/client-generator/src/generators/cli/index.ts index 10d413d62c..1d06e9406f 100644 --- a/packages/client-generator/src/generators/cli/index.ts +++ b/packages/client-generator/src/generators/cli/index.ts @@ -1,10 +1,10 @@ import { join } from 'node:path'; import type { OperationModel } from '../../intermediate-representation/model.js'; -import { groupSlug } from '../../runtime/cli.js'; import type { CodeSample, Generator, SampleContext } from '../types.js'; import { renderCliDocs } from './docs.js'; import { cliAuthSchemes, commandData, renderCliModule } from './render.js'; +import { groupSlug } from './runtime/cli.js'; /** * The cli generator: a bin-ready `.cli.ts` — a zero-dependency, typed diff --git a/packages/client-generator/src/generators/cli/render.ts b/packages/client-generator/src/generators/cli/render.ts index 1518f7c9f3..fb8572e7ae 100644 --- a/packages/client-generator/src/generators/cli/render.ts +++ b/packages/client-generator/src/generators/cli/render.ts @@ -20,7 +20,7 @@ import { type CliAuthScheme, type CliCommand, type CliFlag, -} from '../../runtime/cli.js'; +} from './runtime/cli.js'; // The generated-by banner every emitted module carries (same lines as the pipeline's // `input.banner`, rendered in `//` syntax). diff --git a/packages/client-generator/src/runtime/__tests__/cli.test.ts b/packages/client-generator/src/generators/cli/runtime/__tests__/cli.test.ts similarity index 100% rename from packages/client-generator/src/runtime/__tests__/cli.test.ts rename to packages/client-generator/src/generators/cli/runtime/__tests__/cli.test.ts diff --git a/packages/client-generator/src/runtime/cli.ts b/packages/client-generator/src/generators/cli/runtime/cli.ts similarity index 100% rename from packages/client-generator/src/runtime/cli.ts rename to packages/client-generator/src/generators/cli/runtime/cli.ts diff --git a/packages/client-generator/src/generators/go/AGENTS.md b/packages/client-generator/src/generators/go/AGENTS.md index a3b20e5784..fd53b99863 100644 --- a/packages/client-generator/src/generators/go/AGENTS.md +++ b/packages/client-generator/src/generators/go/AGENTS.md @@ -71,7 +71,7 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies. inside a doc comment is `//` — never `// ` with a trailing space. A change here is verified by the `gofmt -l` bar in the unit suite, at cafe AND large-description scale. -- The runtime is hand-written in `runtime/go/runtime.go` (gofmt-clean, `go vet`-clean) +- The runtime is hand-written in `runtime/runtime.go` in this folder (gofmt-clean, `go vet`-clean) and embedded at prepare time. - Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. @@ -86,7 +86,7 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies. ## The modify loop 1. Edit this skill: state the new behavior or decision. -2. Change `index.ts` (and `runtime/go/runtime.go` for runtime behavior; `gofmt -w` + +2. Change `index.ts` (and `runtime/runtime.go` for runtime behavior; `gofmt -w` + `go vet ./...` it, then `npm run prepare -w @redocly/client-generator`). 3. Verify: `npm run compile`, then `VITEST_SUITE=unit npx vitest run packages/client-generator/src/generators/__tests__/go.test.ts` diff --git a/packages/client-generator/runtime/go/go.mod b/packages/client-generator/src/generators/go/runtime/go.mod similarity index 100% rename from packages/client-generator/runtime/go/go.mod rename to packages/client-generator/src/generators/go/runtime/go.mod diff --git a/packages/client-generator/runtime/go/runtime.go b/packages/client-generator/src/generators/go/runtime/runtime.go similarity index 100% rename from packages/client-generator/runtime/go/runtime.go rename to packages/client-generator/src/generators/go/runtime/runtime.go diff --git a/packages/client-generator/src/generators/php/AGENTS.md b/packages/client-generator/src/generators/php/AGENTS.md index 84c69dc9d4..f91e9f6b72 100644 --- a/packages/client-generator/src/generators/php/AGENTS.md +++ b/packages/client-generator/src/generators/php/AGENTS.md @@ -73,7 +73,7 @@ $idempotencyKey` on mutating methods. - **Parity surface:** auth, retries with `Retry-After` + jittered backoff, per-attempt curl timeouts, middleware callables, pagination (`Pages()` / `Items()` as `\Generator`s), SSE (`iterSse` over a curl_multi pump), multipart. -- The runtime is hand-written in `runtime/php/runtime.php` (`php -l`-clean) and embedded +- The runtime is hand-written in `runtime/runtime.php` in this folder (`php -l`-clean) and embedded at prepare time. `curl_close` is never called (deprecated since PHP 8.5, no-op since 8.0). - Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. @@ -102,7 +102,7 @@ $idempotencyKey` on mutating methods. ## The modify loop 1. Edit this skill: state the new behavior or decision. -2. Change `index.ts` (and `runtime/php/runtime.php` for runtime behavior; `php -l` it, +2. Change `index.ts` (and `runtime/runtime.php` for runtime behavior; `php -l` it, then `npm run prepare -w @redocly/client-generator`). 3. Verify: `npm run compile`, then `VITEST_SUITE=unit npx vitest run packages/client-generator/src/generators/__tests__/php.test.ts` diff --git a/packages/client-generator/runtime/php/runtime.php b/packages/client-generator/src/generators/php/runtime/runtime.php similarity index 100% rename from packages/client-generator/runtime/php/runtime.php rename to packages/client-generator/src/generators/php/runtime/runtime.php diff --git a/packages/client-generator/src/generators/python/AGENTS.md b/packages/client-generator/src/generators/python/AGENTS.md index 308cdaa5cf..2191d88b2c 100644 --- a/packages/client-generator/src/generators/python/AGENTS.md +++ b/packages/client-generator/src/generators/python/AGENTS.md @@ -84,7 +84,7 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a - **Parity surface:** auth (bearer/basic/apiKey), retries with `Retry-After` + jittered backoff, timeouts, idempotency keys, middleware, pagination (`_pages()` / `_items()` + `aiter` mirrors), SSE (`iter_sse`/`aiter_sse`), multipart. -- The runtime is hand-written in `runtime/python/*.py` and embedded as strings at prepare +- The runtime is hand-written in `runtime/*.py` in this folder and embedded as strings at prepare time — generator code never builds runtime logic from templates. - Authored ONLY with the neutral toolkit (`Printer`, naming, schema, pagination helpers) — the dogfooding guard fails otherwise. @@ -100,7 +100,7 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a ## The modify loop 1. Edit this skill: state the new behavior or decision. -2. Change `index.ts` (and `runtime/python/*.py` if runtime behavior changes; then +2. Change `index.ts` (and `runtime/*.py` if runtime behavior changes; then `npm run prepare -w @redocly/client-generator` re-embeds). 3. Verify: `npm run compile`, then `VITEST_SUITE=unit npx vitest run packages/client-generator/src/generators/__tests__/python.test.ts` diff --git a/packages/client-generator/runtime/python/_auth.py b/packages/client-generator/src/generators/python/runtime/_auth.py similarity index 100% rename from packages/client-generator/runtime/python/_auth.py rename to packages/client-generator/src/generators/python/runtime/_auth.py diff --git a/packages/client-generator/runtime/python/_decode.py b/packages/client-generator/src/generators/python/runtime/_decode.py similarity index 100% rename from packages/client-generator/runtime/python/_decode.py rename to packages/client-generator/src/generators/python/runtime/_decode.py diff --git a/packages/client-generator/runtime/python/_errors.py b/packages/client-generator/src/generators/python/runtime/_errors.py similarity index 100% rename from packages/client-generator/runtime/python/_errors.py rename to packages/client-generator/src/generators/python/runtime/_errors.py diff --git a/packages/client-generator/runtime/python/_multipart.py b/packages/client-generator/src/generators/python/runtime/_multipart.py similarity index 100% rename from packages/client-generator/runtime/python/_multipart.py rename to packages/client-generator/src/generators/python/runtime/_multipart.py diff --git a/packages/client-generator/runtime/python/_paginate.py b/packages/client-generator/src/generators/python/runtime/_paginate.py similarity index 100% rename from packages/client-generator/runtime/python/_paginate.py rename to packages/client-generator/src/generators/python/runtime/_paginate.py diff --git a/packages/client-generator/runtime/python/_send.py b/packages/client-generator/src/generators/python/runtime/_send.py similarity index 100% rename from packages/client-generator/runtime/python/_send.py rename to packages/client-generator/src/generators/python/runtime/_send.py diff --git a/packages/client-generator/runtime/python/_sse.py b/packages/client-generator/src/generators/python/runtime/_sse.py similarity index 100% rename from packages/client-generator/runtime/python/_sse.py rename to packages/client-generator/src/generators/python/runtime/_sse.py diff --git a/packages/client-generator/runtime/python/_url.py b/packages/client-generator/src/generators/python/runtime/_url.py similarity index 100% rename from packages/client-generator/runtime/python/_url.py rename to packages/client-generator/src/generators/python/runtime/_url.py diff --git a/packages/client-generator/src/generators/tanstack-query/render.ts b/packages/client-generator/src/generators/tanstack-query/render.ts index 8b8edced90..45b21ff5f0 100644 --- a/packages/client-generator/src/generators/tanstack-query/render.ts +++ b/packages/client-generator/src/generators/tanstack-query/render.ts @@ -21,9 +21,12 @@ import { wrappableOperations, } from '../../contracts/typescript.js'; import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; -import { type ModelPagination, resolveSchemaPointer } from '../../pagination.js'; +import { + type ModelPagination, + type PaginationSpec, + resolveSchemaPointer, +} from '../../pagination.js'; import { codeString, isSafeIdentifier, safeIdent } from '../../printers/typescript.js'; -import type { PaginationSpec } from '../../runtime/types.js'; export type TanstackOptions = { /** Import specifier for the sdk entry the `client` instance and types live in. */ diff --git a/packages/client-generator/src/generators/typescript/response-headers.ts b/packages/client-generator/src/generators/typescript/response-headers.ts index 6033c58713..21450c41a9 100644 --- a/packages/client-generator/src/generators/typescript/response-headers.ts +++ b/packages/client-generator/src/generators/typescript/response-headers.ts @@ -8,7 +8,7 @@ import type { SchemaModel, } from '../../intermediate-representation/model.js'; import { headerPropertyKey, uniqueIdent } from '../../printers/typescript.js'; -import type { ResponseHeaderSpec } from '../../runtime/types.js'; +import type { ResponseHeaderSpec } from './runtime/types.js'; const INDENT = ' '; diff --git a/packages/client-generator/src/runtime/__tests__/auth.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/auth.test.ts similarity index 100% rename from packages/client-generator/src/runtime/__tests__/auth.test.ts rename to packages/client-generator/src/generators/typescript/runtime/__tests__/auth.test.ts diff --git a/packages/client-generator/src/runtime/__tests__/create-client.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/create-client.test.ts similarity index 100% rename from packages/client-generator/src/runtime/__tests__/create-client.test.ts rename to packages/client-generator/src/generators/typescript/runtime/__tests__/create-client.test.ts diff --git a/packages/client-generator/src/runtime/__tests__/errors.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/errors.test.ts similarity index 100% rename from packages/client-generator/src/runtime/__tests__/errors.test.ts rename to packages/client-generator/src/generators/typescript/runtime/__tests__/errors.test.ts diff --git a/packages/client-generator/src/runtime/__tests__/index.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/index.test.ts similarity index 98% rename from packages/client-generator/src/runtime/__tests__/index.test.ts rename to packages/client-generator/src/generators/typescript/runtime/__tests__/index.test.ts index 40ab8c577f..6eff2d6a02 100644 --- a/packages/client-generator/src/runtime/__tests__/index.test.ts +++ b/packages/client-generator/src/generators/typescript/runtime/__tests__/index.test.ts @@ -1,4 +1,4 @@ -import { defineClientSetup, type Middleware } from '../../runtime-contract.js'; +import { defineClientSetup, type Middleware } from '../../../../runtime-contract.js'; import { ApiError, createClient, mergeSetup, type OperationDescriptor } from '../index.js'; const OPS = { diff --git a/packages/client-generator/src/runtime/__tests__/multipart.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/multipart.test.ts similarity index 100% rename from packages/client-generator/src/runtime/__tests__/multipart.test.ts rename to packages/client-generator/src/generators/typescript/runtime/__tests__/multipart.test.ts diff --git a/packages/client-generator/src/runtime/__tests__/paginate.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/paginate.test.ts similarity index 100% rename from packages/client-generator/src/runtime/__tests__/paginate.test.ts rename to packages/client-generator/src/generators/typescript/runtime/__tests__/paginate.test.ts diff --git a/packages/client-generator/src/runtime/__tests__/parse.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/parse.test.ts similarity index 100% rename from packages/client-generator/src/runtime/__tests__/parse.test.ts rename to packages/client-generator/src/generators/typescript/runtime/__tests__/parse.test.ts diff --git a/packages/client-generator/src/runtime/__tests__/retry.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/retry.test.ts similarity index 100% rename from packages/client-generator/src/runtime/__tests__/retry.test.ts rename to packages/client-generator/src/generators/typescript/runtime/__tests__/retry.test.ts diff --git a/packages/client-generator/src/runtime/__tests__/send.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/send.test.ts similarity index 100% rename from packages/client-generator/src/runtime/__tests__/send.test.ts rename to packages/client-generator/src/generators/typescript/runtime/__tests__/send.test.ts diff --git a/packages/client-generator/src/runtime/__tests__/sse.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/sse.test.ts similarity index 100% rename from packages/client-generator/src/runtime/__tests__/sse.test.ts rename to packages/client-generator/src/generators/typescript/runtime/__tests__/sse.test.ts diff --git a/packages/client-generator/src/runtime/__tests__/types.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/types.test.ts similarity index 100% rename from packages/client-generator/src/runtime/__tests__/types.test.ts rename to packages/client-generator/src/generators/typescript/runtime/__tests__/types.test.ts diff --git a/packages/client-generator/src/runtime/__tests__/url.test.ts b/packages/client-generator/src/generators/typescript/runtime/__tests__/url.test.ts similarity index 100% rename from packages/client-generator/src/runtime/__tests__/url.test.ts rename to packages/client-generator/src/generators/typescript/runtime/__tests__/url.test.ts diff --git a/packages/client-generator/src/runtime/auth.ts b/packages/client-generator/src/generators/typescript/runtime/auth.ts similarity index 100% rename from packages/client-generator/src/runtime/auth.ts rename to packages/client-generator/src/generators/typescript/runtime/auth.ts diff --git a/packages/client-generator/src/runtime/create-client.ts b/packages/client-generator/src/generators/typescript/runtime/create-client.ts similarity index 100% rename from packages/client-generator/src/runtime/create-client.ts rename to packages/client-generator/src/generators/typescript/runtime/create-client.ts diff --git a/packages/client-generator/src/runtime/errors.ts b/packages/client-generator/src/generators/typescript/runtime/errors.ts similarity index 100% rename from packages/client-generator/src/runtime/errors.ts rename to packages/client-generator/src/generators/typescript/runtime/errors.ts diff --git a/packages/client-generator/src/runtime/index.ts b/packages/client-generator/src/generators/typescript/runtime/index.ts similarity index 100% rename from packages/client-generator/src/runtime/index.ts rename to packages/client-generator/src/generators/typescript/runtime/index.ts diff --git a/packages/client-generator/src/runtime/multipart.ts b/packages/client-generator/src/generators/typescript/runtime/multipart.ts similarity index 100% rename from packages/client-generator/src/runtime/multipart.ts rename to packages/client-generator/src/generators/typescript/runtime/multipart.ts diff --git a/packages/client-generator/src/runtime/paginate.ts b/packages/client-generator/src/generators/typescript/runtime/paginate.ts similarity index 100% rename from packages/client-generator/src/runtime/paginate.ts rename to packages/client-generator/src/generators/typescript/runtime/paginate.ts diff --git a/packages/client-generator/src/runtime/parse.ts b/packages/client-generator/src/generators/typescript/runtime/parse.ts similarity index 100% rename from packages/client-generator/src/runtime/parse.ts rename to packages/client-generator/src/generators/typescript/runtime/parse.ts diff --git a/packages/client-generator/src/runtime/retry.ts b/packages/client-generator/src/generators/typescript/runtime/retry.ts similarity index 100% rename from packages/client-generator/src/runtime/retry.ts rename to packages/client-generator/src/generators/typescript/runtime/retry.ts diff --git a/packages/client-generator/src/runtime/send.ts b/packages/client-generator/src/generators/typescript/runtime/send.ts similarity index 100% rename from packages/client-generator/src/runtime/send.ts rename to packages/client-generator/src/generators/typescript/runtime/send.ts diff --git a/packages/client-generator/src/runtime/setup.ts b/packages/client-generator/src/generators/typescript/runtime/setup.ts similarity index 100% rename from packages/client-generator/src/runtime/setup.ts rename to packages/client-generator/src/generators/typescript/runtime/setup.ts diff --git a/packages/client-generator/src/runtime/sse.ts b/packages/client-generator/src/generators/typescript/runtime/sse.ts similarity index 100% rename from packages/client-generator/src/runtime/sse.ts rename to packages/client-generator/src/generators/typescript/runtime/sse.ts diff --git a/packages/client-generator/src/runtime/types.ts b/packages/client-generator/src/generators/typescript/runtime/types.ts similarity index 76% rename from packages/client-generator/src/runtime/types.ts rename to packages/client-generator/src/generators/typescript/runtime/types.ts index 5421fe17f9..4965eb9823 100644 --- a/packages/client-generator/src/runtime/types.ts +++ b/packages/client-generator/src/generators/typescript/runtime/types.ts @@ -6,6 +6,15 @@ * incompatible runtime/generated pair fails the consumer's build (the semver skew guard). */ +import type { PaginationSpec } from '../../../pagination.js'; +import type { + ApiErrorLike, + Middleware, + OperationContext, + RequestContext, + RetryConfig, +} from '../../../runtime-contract.js'; + /** How one operation parameter is sent: its location plus OpenAPI query-serialization hints. */ export type ParamSpec = { name: string; @@ -20,41 +29,11 @@ export type SecuritySpec = | { scheme: string; kind: 'bearer' | 'basic' } | { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' }; -/** - * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members). - * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value. - */ -export type PaginationSpec = - | { - style: 'cursor'; - /** The query param the iterator advances with the response's cursor. */ - param: string; - /** Optional page-size query param (recorded for tooling; never set by the runtime). */ - limitParam?: string; - /** Pointer to the next cursor in the page. */ - nextCursor: string; - /** Optional pointer to a boolean "more pages" flag — `false` stops iteration. */ - hasMore?: string; - /** Pointer to the page's item array. */ - items: string; - } - | { - style: 'offset' | 'page'; - /** The numeric query param the iterator advances. */ - param: string; - /** Optional page-size query param (recorded for tooling; never set by the runtime). */ - limitParam?: string; - /** Pointer to the page's item array. */ - items: string; - } - | { - /** RFC 8288: follow the response's `Link` header `rel="next"`; stop when absent. */ - style: 'link'; - /** Optional page-size query param (recorded for tooling; never set by the runtime). */ - limitParam?: string; - /** Pointer to the page's item array. */ - items: string; - }; +// The spec this runtime drives is DEFINED at the package level, beside the resolver +// that produces it (src/pagination.ts); re-exported here so the generated client's +// type surface is unchanged. The embed splices the definition back in (see +// scripts/generate-runtime-sources.mjs). +export type { PaginationSpec } from '../../../pagination.js'; /** The frozen data contract between generated code and the runtime: one operation's wire shape. */ export type OperationDescriptor = { @@ -111,75 +90,18 @@ export type AuthCredentials = { apiKey?: Record; }; -/** Backoff shape: 'fixed' = constant delay; 'exponential' = doubling per attempt. */ -export type RetryStrategy = 'fixed' | 'exponential'; - -/** - * The operation's identity, exposed to middleware for targeting (`ctx.operation`). - * Generated clients instantiate the type parameters with the spec's literal unions - * (`OperationId`/`OperationPath`/`OperationTag`) so a misspelled operation id in a - * middleware comparison fails to compile; the string defaults keep every - * spec-independent consumer (`runtime-contract.ts`, the runtime internals) working - * with the base shape. `tags` stays mutable (`Tag[]`) so setup-contract types - * (byte-locked to generated output) remain assignable through middleware callbacks. - */ -export type OperationContext< - Id extends string = string, - Path extends string = string, - Tag extends string = string, -> = { id: Id; path: Path; tags: Tag[] }; - -/** The mutable request context threaded through the middleware chain. */ -export type RequestContext = { - url: string; - method: string; - headers: Record; - body?: unknown; - operation: Op; -}; - -/** The failed attempt handed to a custom `retryOn`: exactly one of `response`/`error` is set. */ -export type RetryContext = { - attempt: number; - request: RequestContext; - response?: Response; - error?: unknown; -}; - -/** Opt-in retry policy; a per-call override merges field-by-field over the config policy. */ -export type RetryConfig = { - retries?: number; - retryDelay?: number; - retryStrategy?: RetryStrategy; - jitter?: boolean; - retryOn?: (ctx: RetryContext) => boolean | Promise; -}; - -/** - * Structural stand-in for the runtime's ApiError so this module stays import-free - * (pure types); the real `ApiError` class is assignable to it. - */ -export type ApiErrorLike = globalThis.Error & { - url: string; - status: number; - statusText: string; - body: unknown; -}; - -/** One interceptor: any subset of the three hooks. */ -export type Middleware = { - onRequest?: (ctx: RequestContext) => void | Promise; - onResponse?: ( - response: Response, - ctx: RequestContext - ) => Response | void | Promise; - /** Throw mode only: may map/replace the error. */ - // `globalThis.Error` so a spec schema named `Error` cannot shadow it in inline mode. - onError?: ( - error: ApiErrorLike, - ctx: RequestContext - ) => globalThis.Error | Promise; -}; +// The setup contract (ADR-0022): these types are defined at the package level in +// src/runtime-contract.ts — the layer publishers author `--setup` files against — +// and re-exported here. The embed splices the definitions back in. +export type { + ApiErrorLike, + Middleware, + OperationContext, + RequestContext, + RetryConfig, + RetryContext, + RetryStrategy, +} from '../../../runtime-contract.js'; /** Client configuration: transport, defaults, retry policy, middleware, and credentials. */ export type ClientConfig = { diff --git a/packages/client-generator/src/runtime/url.ts b/packages/client-generator/src/generators/typescript/runtime/url.ts similarity index 100% rename from packages/client-generator/src/runtime/url.ts rename to packages/client-generator/src/generators/typescript/runtime/url.ts diff --git a/packages/client-generator/src/index.ts b/packages/client-generator/src/index.ts index ebbe7146e0..9dede60ff3 100644 --- a/packages/client-generator/src/index.ts +++ b/packages/client-generator/src/index.ts @@ -29,7 +29,7 @@ export type { CommandContext, CommandSource, CustomCommand, -} from './runtime/cli.js'; +} from './generators/cli/runtime/cli.js'; // The user-facing pagination rule shapes (`Config.pagination` / `x-redoclyPagination`). export type { PaginationConfig, PaginationRule, PaginationStyle } from './pagination.js'; export type { diff --git a/packages/client-generator/src/pagination.ts b/packages/client-generator/src/pagination.ts index 2cbb46c59b..87ac869486 100644 --- a/packages/client-generator/src/pagination.ts +++ b/packages/client-generator/src/pagination.ts @@ -15,7 +15,42 @@ import { type OperationModel, type SchemaModel, } from './intermediate-representation/model.js'; -import type { PaginationSpec } from './runtime/types.js'; + +/** + * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members). + * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value. + */ +export type PaginationSpec = + | { + style: 'cursor'; + /** The query param the iterator advances with the response's cursor. */ + param: string; + /** Optional page-size query param (recorded for tooling; never set by the runtime). */ + limitParam?: string; + /** Pointer to the next cursor in the page. */ + nextCursor: string; + /** Optional pointer to a boolean "more pages" flag — `false` stops iteration. */ + hasMore?: string; + /** Pointer to the page's item array. */ + items: string; + } + | { + style: 'offset' | 'page'; + /** The numeric query param the iterator advances. */ + param: string; + /** Optional page-size query param (recorded for tooling; never set by the runtime). */ + limitParam?: string; + /** Pointer to the page's item array. */ + items: string; + } + | { + /** RFC 8288: follow the response's `Link` header `rel="next"`; stop when absent. */ + style: 'link'; + /** Optional page-size query param (recorded for tooling; never set by the runtime). */ + limitParam?: string; + /** Pointer to the page's item array. */ + items: string; + }; /** The pagination styles the generated runtime can drive. */ export type PaginationStyle = 'cursor' | 'offset' | 'page' | 'link'; diff --git a/packages/client-generator/src/runtime-contract.ts b/packages/client-generator/src/runtime-contract.ts index ba149069e0..cc724cd851 100644 --- a/packages/client-generator/src/runtime-contract.ts +++ b/packages/client-generator/src/runtime-contract.ts @@ -1,18 +1,77 @@ // The public, spec-independent runtime contract a publisher's `--setup` file imports. -// These are the runtime's own types (src/runtime/types.ts) — the very module the -// generated client embeds (inline) or imports (package) — so the contract cannot -// drift from the generated output. - -import type { Middleware, RequestContext, RetryConfig } from './runtime/types.js'; - -export type { - Middleware, - OperationContext, - RequestContext, - RetryConfig, - RetryContext, - RetryStrategy, -} from './runtime/types.js'; +// These types are DEFINED here, at the package level, and the TypeScript runtime +// re-exports them (ADR-0022) — the embed splices the definitions into the generated +// client's types module, so the contract cannot drift from the generated output. + +/** Backoff shape: 'fixed' = constant delay; 'exponential' = doubling per attempt. */ +export type RetryStrategy = 'fixed' | 'exponential'; + +/** + * The operation's identity, exposed to middleware for targeting (`ctx.operation`). + * Generated clients instantiate the type parameters with the spec's literal unions + * (`OperationId`/`OperationPath`/`OperationTag`) so a misspelled operation id in a + * middleware comparison fails to compile; the string defaults keep every + * spec-independent consumer (`runtime-contract.ts`, the runtime internals) working + * with the base shape. `tags` stays mutable (`Tag[]`) so setup-contract types + * (byte-locked to generated output) remain assignable through middleware callbacks. + */ +export type OperationContext< + Id extends string = string, + Path extends string = string, + Tag extends string = string, +> = { id: Id; path: Path; tags: Tag[] }; + +/** The mutable request context threaded through the middleware chain. */ +export type RequestContext = { + url: string; + method: string; + headers: Record; + body?: unknown; + operation: Op; +}; + +/** The failed attempt handed to a custom `retryOn`: exactly one of `response`/`error` is set. */ +export type RetryContext = { + attempt: number; + request: RequestContext; + response?: Response; + error?: unknown; +}; + +/** Opt-in retry policy; a per-call override merges field-by-field over the config policy. */ +export type RetryConfig = { + retries?: number; + retryDelay?: number; + retryStrategy?: RetryStrategy; + jitter?: boolean; + retryOn?: (ctx: RetryContext) => boolean | Promise; +}; + +/** + * Structural stand-in for the runtime's ApiError so this module stays import-free + * (pure types); the real `ApiError` class is assignable to it. + */ +export type ApiErrorLike = globalThis.Error & { + url: string; + status: number; + statusText: string; + body: unknown; +}; + +/** One interceptor: any subset of the three hooks. */ +export type Middleware = { + onRequest?: (ctx: RequestContext) => void | Promise; + onResponse?: ( + response: Response, + ctx: RequestContext + ) => Response | void | Promise; + /** Throw mode only: may map/replace the error. */ + // `globalThis.Error` so a spec schema named `Error` cannot shadow it in inline mode. + onError?: ( + error: ApiErrorLike, + ctx: RequestContext + ) => globalThis.Error | Promise; +}; /** * The spec-independent subset of a client's `ClientConfig` a publisher may bake in From a2414d3564138589a74218b40cf7097de3f41ef4 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 22 Aug 2026 23:56:03 +0300 Subject: [PATCH 30/35] feat: runtime 'module' writes the runtime as real files beside the TypeScript client and the generated CLI --- docs/@v2/commands/generate-client.md | 18 ++++- docs/@v2/configuration/reference/client.md | 2 +- docs/@v2/guides/use-generated-client.md | 5 +- packages/cli/src/commands/generate-client.ts | 2 +- packages/cli/src/index.ts | 4 +- .../src/emitters/inline-runtime.ts | 76 ++++++++++++++++-- .../generators/cli/__tests__/render.test.ts | 9 +++ .../src/generators/cli/index.ts | 15 +++- .../src/generators/cli/render.ts | 6 +- .../client-generator/src/generators/meta.ts | 11 +++ .../client-generator/src/generators/types.ts | 5 +- .../__tests__/client-assembly.test.ts | 63 ++++++++++++++- .../generators/typescript/client-assembly.ts | 75 ++++++++++++++---- .../src/generators/typescript/index.ts | 10 ++- packages/client-generator/src/types.ts | 5 +- packages/core/src/types/redocly-yaml.ts | 2 +- tests/e2e/generate-client/cli.test.ts | 32 +++++++- .../.claude/skills/php-generator/SKILL.md | 2 +- .../generate-client/module-runtime.test.ts | 78 +++++++++++++++++++ 19 files changed, 379 insertions(+), 41 deletions(-) create mode 100644 tests/e2e/generate-client/module-runtime.test.ts diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index e3a264ad19..c22015972f 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -76,7 +76,7 @@ redocly generate-client [--help] [--version] | `api` | string | The file path to the OpenAPI description, a URL, or an `apis:` alias. Omit it to generate a client for each api that has a `client` block or `clientOutput`. | | `--output`, `-o` | string | The output path (it must end in `.ts`). In multi-file modes, this is the entry file. Defaults to the `clientOutput` of the api, else `.client.ts` next to the configuration file. Use this option only when you generate one API. | | `--output-mode` | string | The file layout. See [Choose an output mode](#choose-an-output-mode).
**Possible values:** `single`, `split`. Default: `single`. | -| `--runtime` | string | The location of the client engine.
**Possible values:** `inline`. Default: `inline`. | +| `--runtime` | string | The location of the client engine.
**Possible values:** `inline`, `module`. Default: `inline`. | | `--import-ext` | string | The extension in the generated relative imports. See [Run with Node directly](../guides/use-generated-client.md#run-with-node-directly).
**Possible values:** `js` (the tsc/bundler convention), `ts` (for Node's built-in type stripping). Default: `js`. | | `--generator` | [string] | The generator to run: a built-in name, or the path or package of a custom generator. Repeat the flag to run more than one generator. Default value is `typescript`. See [Generators](../guides/use-generated-client.md#generators) for the full list. | | `--args-style` | string | Sets how you pass inputs to operations. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `grouped`, `flat`. Default: `grouped`. | @@ -141,6 +141,22 @@ The `--output-mode` flag controls how the command splits the client into files: redocly generate-client openapi.yaml -o src/api/client.ts --output-mode split ``` +### Choose a runtime + +The `--runtime` flag controls where the client engine lives: + +- `inline` (default): the engine is embedded in the generated file, so the client is one self-contained file. +- `module`: the command writes the engine as real files in a `runtime/` folder beside the client, and the client imports them relatively. + Several generated clients in one repository can share one `runtime/` folder, and you can read the engine as ordinary source files. + The files are still machine-owned: the command regenerates them on every run. + +The `typescript` and `cli` generators support `module`. +The `python`, `go`, and `php` generators support only `inline` for now. + +```bash +redocly generate-client openapi.yaml -o src/api/client.ts --runtime module +``` + ## Resources - **[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/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index 8f31d7faf3..ae33f763e2 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -26,7 +26,7 @@ As an alternative, pass `pagination` to the programmatic `generateClient(...)`. | ----------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `generators` | [string] | The generators to run, in order. Each entry is a built-in name (`typescript`, `zod`, `tanstack-query` or its `-vue`/`-svelte`/`-solid` variants, `swr`, `mock`, `transformers`, `cli`, `python`, `go`, `php`), or the path or package name of a custom generator. | | `outputMode` | string | The file layout: `single` or `split`. This option applies to TypeScript output only. The `python`, `go`, and `php` SDKs always emit one self-contained file. | -| `runtime` | string | The runtime distribution: `inline` (the runtime is embedded in the generated output). | +| `runtime` | string | The runtime distribution: `inline` (the runtime is embedded in the generated output) or `module` (the runtime is written as real files in a `runtime/` folder beside the client). | | `importExt` | string | The extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). This option applies to TypeScript output only. | | `argsStyle` | string | How the client receives operation inputs: `grouped` (default) groups them by transport layer (`path`, `query`, `headers`, `cookies`, `body`), and `flat` merges them into one object. This option applies to TypeScript output only. Each language SDK follows its own idiom (keyword arguments, named arguments, a params struct). | | `errorMode` | string | How operations report HTTP errors: `throw` or `result`. The `python` SDK implements both. The `go` and `php` SDKs support only `throw`, because that is the language idiom, and they reject `result`. | diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 4e6ea93048..c9d5ef99eb 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -483,11 +483,10 @@ client.auth.bearer(async () => await getFreshAccessToken()); The client resolves the provider for each request, so a refreshed token takes effect without reconfiguration. For **multiple independent instances** with different credentials, build extra clients from the same generated descriptors. -The generated module exports `createClient`, the `OPERATIONS` descriptors, and the `Ops` type in both runtimes: +The generated module exports `createClient`, the `OPERATIONS` descriptors, and the `Ops` type in both runtime modes: ```ts -import { createClient } from '@redocly/client-generator'; -import { OPERATIONS, type Ops } from './client.ts'; +import { createClient, OPERATIONS, type Ops } from './client.ts'; const internal = createClient(OPERATIONS, { serverUrl: 'https://api.example.com', diff --git a/packages/cli/src/commands/generate-client.ts b/packages/cli/src/commands/generate-client.ts index 2ec3395606..a302647b88 100644 --- a/packages/cli/src/commands/generate-client.ts +++ b/packages/cli/src/commands/generate-client.ts @@ -32,7 +32,7 @@ export type GenerateClientCommandArgv = { config?: string; 'server-url'?: string; 'output-mode'?: 'single' | 'split'; - runtime?: 'inline'; + runtime?: 'inline' | 'module'; 'import-ext'?: 'js' | 'ts'; 'go-package'?: string; 'args-style'?: 'flat' | 'grouped'; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5d52df9ffe..0993271f1b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -896,8 +896,8 @@ yargs(hideBin(process.argv)) }, runtime: { describe: - "Runtime distribution: 'inline' (default) embeds the runtime in the generated file.", - choices: ['inline'] as const, + "Runtime distribution: 'inline' (default) embeds the runtime in the generated file; 'module' writes it as real files in a runtime/ folder beside the client.", + choices: ['inline', 'module'] as const, requiresArg: true, }, docs: { diff --git a/packages/client-generator/src/emitters/inline-runtime.ts b/packages/client-generator/src/emitters/inline-runtime.ts index efc7e87c10..f1981828f3 100644 --- a/packages/client-generator/src/emitters/inline-runtime.ts +++ b/packages/client-generator/src/emitters/inline-runtime.ts @@ -5,7 +5,11 @@ // capabilities this API needs. Pure string concatenation: no `typescript` at // generate time. -import { RUNTIME_SOURCES_STRIPPED, type RuntimeModuleName } from './runtime-sources.js'; +import { + RUNTIME_SOURCES, + RUNTIME_SOURCES_STRIPPED, + type RuntimeModuleName, +} from './runtime-sources.js'; /** Which optional runtime capabilities the generated client must embed. */ export type InlineRuntimeNeeds = { @@ -19,10 +23,10 @@ export type InlineRuntimeNeeds = { const HEADER = "// ─── Embedded runtime (@redocly/client-generator, assembled per this API's needs) ───"; -/** The embedded runtime source block: stripped modules in dependency order + the factory. */ -export function assembleInlineRuntime(needs: InlineRuntimeNeeds): string { - // Import-graph topological order; the optional capability modules slot in where the - // package barrel would import them (core never imports them statically). +/** The per-needs module set, in import-graph topological order (both modes share it). */ +function runtimeModules(needs: InlineRuntimeNeeds): RuntimeModuleName[] { + // The optional capability modules slot in where the runtime barrel would import + // them (core never imports them statically). const modules: RuntimeModuleName[] = ['types.ts', 'errors.ts', 'url.ts', 'parse.ts', 'retry.ts']; if (needs.multipart) modules.push('multipart.ts'); if (needs.auth) modules.push('auth.ts'); @@ -33,18 +37,78 @@ export function assembleInlineRuntime(needs: InlineRuntimeNeeds): string { modules.push('send.ts'); if (needs.sse) modules.push('sse.ts'); modules.push('create-client.ts'); + return modules; +} + +/** The embedded runtime source block: stripped modules in dependency order + the factory. */ +export function assembleInlineRuntime(needs: InlineRuntimeNeeds): string { return [ HEADER, - ...modules.map((name) => RUNTIME_SOURCES_STRIPPED[name]), + ...runtimeModules(needs).map((name) => RUNTIME_SOURCES_STRIPPED[name]), clientFactory(needs), ].join('\n\n'); } +/** + * The runtime as real files (`runtime: 'module'`): the same per-needs modules, RAW — + * imports intact, exactly as authored — plus `factory.ts`, the per-needs `createClient` + * wiring as a module importing what it references from its siblings. `importExt` + * rewrites the intra-runtime specifiers for consumers that resolve them literally. + */ +export function runtimeModuleFiles( + needs: InlineRuntimeNeeds, + importExt: 'js' | 'ts' = 'js' +): Array<{ name: string; content: string }> { + const files = [ + ...runtimeModules(needs).map((name) => ({ name, content: RUNTIME_SOURCES[name] })), + { name: 'factory.ts', content: moduleFactory(needs) }, + ]; + if (importExt === 'js') return files; + return files.map(({ name, content }) => ({ + name, + content: content.replace(/(from '\.\/[a-z-]+)\.js'/g, "$1.ts'"), + })); +} + +/** `factory.ts`: the sibling-module equivalent of the inline factory block. */ +function moduleFactory(needs: InlineRuntimeNeeds): string { + const imports = [ + "import { createClientCore } from './create-client.js';", + ...(needs.multipart ? ["import { toFormData } from './multipart.js';"] : []), + ...(needs.auth ? ["import { resolveAuth } from './auth.js';"] : []), + ...(needs.paginate + ? ["import { items, itemsByLink, pages, pagesByLink } from './paginate.js';"] + : []), + ...(needs.sse ? ["import { sse } from './sse.js';"] : []), + `import type { + Client, + ClientConfig, + OperationContext, + OperationDescriptor, + OpsShape, +} from './types.js';`, + ]; + // The client entry re-exports this module, so the factory carries the same public + // surface the inline embed leaves in module scope (the kept-export set). + const reexports = [ + "export { ApiError, TimeoutError } from './errors.js';", + "export { defaultRetryOn } from './retry.js';", + ...(needs.setup ? ["export { mergeSetup } from './setup.js';"] : []), + "export type * from './types.js';", + ]; + return [imports.join('\n'), clientFactory(needs), reexports.join('\n')].join('\n\n'); +} + /** The cli engine (`runCli` + types) stripped for embedding into `.cli.ts`. */ export function embedCliRuntime(): string { return RUNTIME_SOURCES_STRIPPED['cli.ts']; } +/** The cli engine RAW, for `runtime: 'module'` (written as `runtime/cli.ts`). */ +export function cliRuntimeSource(): string { + return RUNTIME_SOURCES['cli.ts']; +} + // The embedded equivalent of the package barrel's `createClient`: `createClientCore` // with only the included capabilities wired. EXPORTED — the design spec promises the // generated module re-exports `createClient`/`OPERATIONS`/`Ops` so apps can build diff --git a/packages/client-generator/src/generators/cli/__tests__/render.test.ts b/packages/client-generator/src/generators/cli/__tests__/render.test.ts index bea34bd9ef..3155330ee9 100644 --- a/packages/client-generator/src/generators/cli/__tests__/render.test.ts +++ b/packages/client-generator/src/generators/cli/__tests__/render.test.ts @@ -227,6 +227,15 @@ describe('renderCliModule', () => { zodSelected: false, }; + it('runtime: module imports the engine from ./runtime/cli instead of embedding it', () => { + const out = renderCliModule(MODEL, { ...options, runtime: 'module' }); + expect(out).toContain( + 'import { invokedName, runCli, type CliCommand, type CliWiring } from "./runtime/cli.js";' + ); + expect(out).not.toContain('function parseInvocation'); // no embedded engine + expect(out).toContain('export { runCli };'); // composition contract holds in both modes + }); + it('emits a shebang entry that wires node bindings and embeds the cli runtime inline', () => { const out = renderCliModule(MODEL, options); expect(out.startsWith('#!/usr/bin/env node')).toBe(true); diff --git a/packages/client-generator/src/generators/cli/index.ts b/packages/client-generator/src/generators/cli/index.ts index 1d06e9406f..0c9c9b4bf7 100644 --- a/packages/client-generator/src/generators/cli/index.ts +++ b/packages/client-generator/src/generators/cli/index.ts @@ -1,5 +1,6 @@ import { join } from 'node:path'; +import { cliRuntimeSource } from '../../emitters/inline-runtime.js'; import type { OperationModel } from '../../intermediate-representation/model.js'; import type { CodeSample, Generator, SampleContext } from '../types.js'; import { renderCliDocs } from './docs.js'; @@ -12,15 +13,25 @@ import { groupSlug } from './runtime/cli.js'; * bodies, env auth, `--page-all`, SSE/blob output, a documented exit-code * contract). Requires `typescript` (throw mode); wires zod validation when co-selected. */ -export const cliGenerator: Generator = ({ model, output, emit, selected, pagination }) => { +export const cliGenerator: Generator = ({ model, output, banner, emit, selected, pagination }) => { const content = renderCliModule(model, { stem: output.stem, importExt: emit.importExt ?? 'js', zodSelected: selected?.includes('zod') ?? false, pagination, argsStyle: emit.argsStyle ?? 'grouped', + runtime: emit.runtime ?? 'inline', }); - return [{ path: join(output.dir, `${output.stem}.cli.ts`), content }]; + const entry = { path: join(output.dir, `${output.stem}.cli.ts`), content }; + if (emit.runtime !== 'module') return [entry]; + const header = banner.map((line) => `// ${line}`).join('\n'); + return [ + entry, + { + path: join(output.dir, 'runtime', 'cli.ts'), + content: `${header}\n\n${cliRuntimeSource().trim()}\n`, + }, + ]; }; /** diff --git a/packages/client-generator/src/generators/cli/render.ts b/packages/client-generator/src/generators/cli/render.ts index fb8572e7ae..8fd9edacba 100644 --- a/packages/client-generator/src/generators/cli/render.ts +++ b/packages/client-generator/src/generators/cli/render.ts @@ -176,6 +176,8 @@ export type CliModuleOptions = { pagination?: ModelPagination; /** The sibling client's call shape, which the dispatcher builds its inputs for. */ argsStyle?: 'grouped' | 'flat'; + /** `'module'` imports the engine from `./runtime/cli` instead of embedding it. */ + runtime?: 'inline' | 'module'; }; /** @@ -236,7 +238,9 @@ export function renderCliModule(model: ApiModel, options: CliModuleOptions): str ? [`import { zodValidation } from "./${options.stem}.zod.${options.importExt}";`] : []), ].join('\n'), - '// ─── Embedded cli engine (@redocly/client-generator) ───\n' + embedCliRuntime(), + options.runtime === 'module' + ? `import { invokedName, runCli, type CliCommand, type CliWiring } from "./runtime/cli.${options.importExt}";` + : '// ─── Embedded cli engine (@redocly/client-generator) ───\n' + embedCliRuntime(), `export const COMMANDS: CliCommand[] = ${codeJson(commands, 2)};`, ...(options.zodSelected ? [ diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index 6bdffe67c2..622553d56f 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -150,6 +150,17 @@ export function validateSelection( } const errorMode = emit.errorMode ?? 'throw'; const dateType = emit.dateType ?? 'string'; + // Module-runtime coverage grows generator by generator (ADR-0022); the languages + // whose emitters still embed only get a clear answer instead of inline output that + // silently ignored the choice. + if (emit.runtime === 'module') { + const inlineOnly = names.find((name) => ['python', 'go', 'php'].includes(name)); + if (inlineOnly !== undefined) { + throw new NotSupportedError( + `The "${inlineOnly}" generator supports only the inline runtime for now — drop --runtime module or generate it separately.` + ); + } + } for (const name of names) { const descriptor = registry.get(name); if (!descriptor) { diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index 81b01fff7d..4c3c5f95b9 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -52,8 +52,9 @@ export type EmitOptions = { * via `mergeSetup`. Absent when no `--setup` is given. */ setup?: string; - /** Runtime distribution: 'inline' (default) embeds the runtime in the generated file. */ - runtime?: 'inline'; + /** Runtime distribution: `'inline'` (default) embeds the runtime in the generated + * file; `'module'` writes it as real files in a `runtime/` folder beside the client. */ + runtime?: 'inline' | 'module'; /** * Extension used in generated relative import specifiers (the split entry's schemas * re-export and each satellite's sdk import). `'js'` (default) is the tsc/bundler diff --git a/packages/client-generator/src/generators/typescript/__tests__/client-assembly.test.ts b/packages/client-generator/src/generators/typescript/__tests__/client-assembly.test.ts index 75c9dd9ad3..16926bb018 100644 --- a/packages/client-generator/src/generators/typescript/__tests__/client-assembly.test.ts +++ b/packages/client-generator/src/generators/typescript/__tests__/client-assembly.test.ts @@ -11,7 +11,7 @@ import { import type { ApiModel } from '../../../intermediate-representation/model.js'; import { resolveModelPagination } from '../../../pagination.js'; import type { EmitOptions } from '../../types.js'; -import { emitClientSingleFile } from '../client-assembly.js'; +import { emitClientSingleFile, emitRuntimeFiles } from '../client-assembly.js'; /** * The emitted client with the embedded runtime block cut out, so assertions and the @@ -414,6 +414,67 @@ describe('emitClientSingleFile (embedded runtime)', () => { // full inline client by the e2e cafe.snapshot.ts. }); +describe('runtime: module', () => { + it('writes the per-needs modules + the factory, and the entry imports them relatively', () => { + const files = emitRuntimeFiles(CAFE, { runtime: 'module' }); + // CAFE needs multipart, auth, and sse — no setup, no pagination. + expect(files.map((file) => file.name)).toEqual([ + 'types.ts', + 'errors.ts', + 'url.ts', + 'parse.ts', + 'retry.ts', + 'multipart.ts', + 'auth.ts', + 'send.ts', + 'sse.ts', + 'create-client.ts', + 'factory.ts', + ]); + const factory = files.find((file) => file.name === 'factory.ts')!.content; + expect(factory).toContain("import { createClientCore } from './create-client.js';"); + expect(factory).toContain("import { toFormData } from './multipart.js';"); + expect(factory).toContain("import { resolveAuth } from './auth.js';"); + expect(factory).toContain("import { sse } from './sse.js';"); + expect(factory).not.toContain("from './paginate.js'"); + expect(factory).toContain("export { ApiError, TimeoutError } from './errors.js';"); + expect(factory).toContain("export type * from './types.js';"); + // The raw modules keep their imports — nothing is stripped in module mode. + const send = files.find((file) => file.name === 'send.ts')!.content; + expect(send).toContain("from './errors.js'"); + + const entry = emitClientSingleFile(CAFE, { runtime: 'module' }); + expect(entry).toContain("import { createClient } from './runtime/factory.js';"); + expect(entry).toContain("import type { OperationDescriptor } from './runtime/types.js';"); + expect(entry).toContain("export * from './runtime/factory.js';"); + expect(entry).not.toContain('// ─── Embedded runtime'); + }); + + it('inline mode writes no runtime files', () => { + expect(emitRuntimeFiles(CAFE, {})).toEqual([]); + }); + + it('importExt ts rewrites the intra-runtime specifiers so Node can strip types directly', () => { + const files = emitRuntimeFiles(CAFE, { runtime: 'module', importExt: 'ts' }); + const factory = files.find((file) => file.name === 'factory.ts')!.content; + expect(factory).toContain("import { createClientCore } from './create-client.ts';"); + expect(factory).not.toContain(".js'"); + const entry = emitClientSingleFile(CAFE, { runtime: 'module', importExt: 'ts' }); + expect(entry).toContain("import { createClient } from './runtime/factory.ts';"); + }); + + it('a baked setup imports mergeSetup and the config types from the runtime', () => { + const entry = emitClientSingleFile(CAFE, { runtime: 'module', setup: '{}' }); + expect(entry).toContain("import { createClient, mergeSetup } from './runtime/factory.js';"); + expect(entry).toContain('ClientConfig'); + const files = emitRuntimeFiles(CAFE, { runtime: 'module', setup: '{}' }); + expect(files.find((file) => file.name === 'setup.ts')).toBeDefined(); + expect(files.find((file) => file.name === 'factory.ts')!.content).toContain( + "export { mergeSetup } from './setup.js';" + ); + }); +}); + describe('emitClientSingleFile — pagination', () => { const PAGINATED = modelWith([listOrders, getOrder], { schemas: [...SCHEMAS, ORDER_PAGE] }); const config = { operations: { listOrders: CURSOR_RULE } }; diff --git a/packages/client-generator/src/generators/typescript/client-assembly.ts b/packages/client-generator/src/generators/typescript/client-assembly.ts index 34d76ba45a..42c2ab53aa 100644 --- a/packages/client-generator/src/generators/typescript/client-assembly.ts +++ b/packages/client-generator/src/generators/typescript/client-assembly.ts @@ -6,7 +6,11 @@ // guards into a sibling `.schemas.ts` the entry re-exports (`emitClientSplit`). // Text templates throughout — no `typescript` at generate time. -import { assembleInlineRuntime } from '../../emitters/inline-runtime.js'; +import { + assembleInlineRuntime, + type InlineRuntimeNeeds, + runtimeModuleFiles, +} from '../../emitters/inline-runtime.js'; import { allOperations, type ApiModel, @@ -30,6 +34,53 @@ export function emitClientSingleFile(model: ApiModel, options: EmitOptions = {}) return emitClient(model, options).entry; } +/** Which optional runtime capabilities this API needs (drives both distribution modes). */ +export function runtimeNeeds(model: ApiModel, options: EmitOptions): InlineRuntimeNeeds { + const ops = allOperations(model.services); + return { + multipart: ops.some((op) => op.requestBody && isTypedMultipart(op.requestBody)), + // Auth sugar needs schemes; `resolveAuth` fires when a descriptor carries + // `security` — a valid spec implies the former, but embed on either. + auth: model.securitySchemes.length > 0 || ops.some((op) => op.security.length > 0), + sse: ops.some((op) => op.sse !== undefined), + setup: !!options.setup, + paginate: (options.pagination ?? new Map()).size > 0, + }; +} + +/** + * `runtime: 'module'`: the runtime files written into `runtime/` beside the client — + * the raw per-needs modules plus the generated factory, each under the standard banner. + */ +export function emitRuntimeFiles( + model: ApiModel, + options: EmitOptions +): Array<{ name: string; content: string }> { + if (options.runtime !== 'module') return []; + return runtimeModuleFiles(runtimeNeeds(model, options), options.importExt ?? 'js').map( + ({ name, content }) => ({ name, content: `${HEADER}\n\n${content.trim()}\n` }) + ); +} + +/** + * The module-mode replacement for the embedded block: the entry imports what its own + * code references and re-exports the factory's public surface (the same names the + * inline embed leaves in module scope). + */ +function runtimeImports(options: EmitOptions, ctx: EmitContext, hasOps: boolean): string { + const ext = options.importExt ?? 'js'; + const typeNames = [ + 'OperationDescriptor', + ...(options.setup ? ['ClientConfig', 'Middleware'] : []), + ...(ctx.errorMode === 'result' && hasOps ? ['Result'] : []), + ].sort(); + return [ + `import { createClient${options.setup ? ', mergeSetup' : ''} } from './runtime/factory.${ext}';`, + `import type { ${typeNames.join(', ')} } from './runtime/types.${ext}';`, + `export * from './runtime/factory.${ext}';`, + ].join('\n'); +} + /** * `split` mode: the same client with the schema types + type guards carved out into a * sibling `.schemas.ts`. The entry file re-exports the schemas module @@ -64,8 +115,6 @@ function emitClient( schemas: model.schemas, pagination, }; - const hasSse = ops.some((op) => op.sse !== undefined); - const wiring = ops.length > 0 ? [ @@ -78,15 +127,10 @@ function emitClient( 'export const OPERATIONS = {} as const satisfies Record;', ]; - const runtimeSection = assembleInlineRuntime({ - multipart: ops.some((op) => op.requestBody && isTypedMultipart(op.requestBody)), - // Auth sugar needs schemes; `resolveAuth` fires when a descriptor carries - // `security` — a valid spec implies the former, but embed on either. - auth: model.securitySchemes.length > 0 || ops.some((op) => op.security.length > 0), - sse: hasSse, - setup: !!options.setup, - paginate: pagination.size > 0, - }); + const runtimeSection = + options.runtime === 'module' + ? runtimeImports(options, ctx, ops.length > 0) + : assembleInlineRuntime(runtimeNeeds(model, options)); const schemaSection = [ renderTypeAliases(model.schemas, ctx.dateType), renderTypeGuards(model.schemas), @@ -103,13 +147,15 @@ function emitClient( // initializer — after it for readability, before `client` so every declaration the // module-init call chain touches (hoisted functions AND any future top-level const) // is already evaluated. + const moduleMode = options.runtime === 'module'; if (splitStem === undefined) { return { entry: banner([ HEADER, renderTitleComment(model), + ...(moduleMode ? [runtimeSection] : []), [schemaSection, bodySection].filter((section) => section.length > 0).join('\n\n'), - runtimeSection, + ...(moduleMode ? [] : [runtimeSection]), clientSection(options, ctx, model), sugar, ]), @@ -121,11 +167,12 @@ function emitClient( entry: banner([ HEADER, renderTitleComment(model), + ...(moduleMode ? [runtimeSection] : []), hasSchemas ? schemaLinks(model, ctx, `./${splitStem}.schemas.${options.importExt ?? 'js'}`) : '', bodySection, - runtimeSection, + ...(moduleMode ? [] : [runtimeSection]), clientSection(options, ctx, model), sugar, ]), diff --git a/packages/client-generator/src/generators/typescript/index.ts b/packages/client-generator/src/generators/typescript/index.ts index 1706b3ea66..dbe80773d8 100644 --- a/packages/client-generator/src/generators/typescript/index.ts +++ b/packages/client-generator/src/generators/typescript/index.ts @@ -3,7 +3,7 @@ import { join } from 'node:path'; import { renderReferencePage } from '../../authoring/reference-page.js'; import type { OperationModel } from '../../intermediate-representation/model.js'; import type { CodeSample, Generator, SampleContext } from '../types.js'; -import { emitClientSingleFile, emitClientSplit } from './client-assembly.js'; +import { emitClientSingleFile, emitClientSplit, emitRuntimeFiles } from './client-assembly.js'; import { packageIdents } from './descriptor.js'; /** @@ -16,6 +16,11 @@ import { packageIdents } from './descriptor.js'; * `.ts` (everything else, which `export *`s the schemas module). */ export const typescriptGenerator: Generator = ({ model, output, outputMode, emit }) => { + // `runtime: 'module'` adds the per-needs runtime files beside the client. + const runtime = emitRuntimeFiles(model, emit).map(({ name, content }) => ({ + path: join(output.dir, 'runtime', name), + content, + })); if (outputMode === 'split') { const { dir, stem } = output; const { entry, schemas } = emitClientSplit(model, emit, stem); @@ -24,9 +29,10 @@ export const typescriptGenerator: Generator = ({ model, output, outputMode, emit ? [] : [{ path: join(dir, `${stem}.schemas.ts`), content: schemas }]), { path: output.path, content: entry }, + ...runtime, ]; } - return [{ path: output.path, content: emitClientSingleFile(model, emit) }]; + return [{ path: output.path, content: emitClientSingleFile(model, emit) }, ...runtime]; }; /** diff --git a/packages/client-generator/src/types.ts b/packages/client-generator/src/types.ts index 39c041bfc4..832d478a1b 100644 --- a/packages/client-generator/src/types.ts +++ b/packages/client-generator/src/types.ts @@ -81,8 +81,9 @@ export type GenerateClientOptions = { * across all output modes. */ setup?: string; - /** Runtime distribution: 'inline' (default) embeds the runtime in the generated file. */ - runtime?: 'inline'; + /** Runtime distribution: `'inline'` (default) embeds the runtime in the generated + * file; `'module'` writes it as real files in a `runtime/` folder beside the client. */ + runtime?: 'inline' | 'module'; /** Extension 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`). */ diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index 042cac1201..8dc7ccdf42 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -377,7 +377,7 @@ const Client: NodeType = { argsStyle: { enum: ['flat', 'grouped'] }, serverUrl: { type: 'string' }, outputMode: { enum: ['single', 'split'] }, - runtime: { enum: ['inline'] }, + runtime: { enum: ['inline', 'module'] }, importExt: { enum: ['js', 'ts'] }, goPackage: { type: 'string' }, cliOutput: { type: 'string' }, diff --git a/tests/e2e/generate-client/cli.test.ts b/tests/e2e/generate-client/cli.test.ts index a9929b50d3..c2551d9b46 100644 --- a/tests/e2e/generate-client/cli.test.ts +++ b/tests/e2e/generate-client/cli.test.ts @@ -1,5 +1,5 @@ import { spawnSync, type ChildProcess } from 'node:child_process'; -import { existsSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -80,6 +80,36 @@ describe('generate-client cli generator (end-to-end)', () => { rmSync(stripDir, { recursive: true, force: true }); }); + it('--runtime module writes runtime/cli.ts and the entry runs through it', () => { + const moduleDir = join(consumerDir, 'client-module'); + rmSync(moduleDir, { recursive: true, force: true }); + generate(fixture, join(moduleDir, 'client.ts'), [ + '--generator', + 'typescript', + '--generator', + 'zod', + '--generator', + 'cli', + '--runtime', + 'module', + ]); + writeFileSync(join(moduleDir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); + try { + const entry = readFileSync(join(moduleDir, 'client.cli.ts'), 'utf-8'); + expect(entry).toContain('from "./runtime/cli.js"'); + expect(entry).not.toContain('function parseInvocation'); + expect(existsSync(join(moduleDir, 'runtime', 'cli.ts'))).toBe(true); + const help = spawnSync(tsxBin, [join(moduleDir, 'client.cli.ts'), '--help'], { + cwd: moduleDir, + encoding: 'utf-8', + }); + expect(help.status, help.stderr).toBe(0); + expect(help.stdout).toContain('Usage:'); + } finally { + rmSync(moduleDir, { 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( 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 index 1508aba0ed..6ffbc4cfd5 100644 --- 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 @@ -74,7 +74,7 @@ $idempotencyKey` on mutating methods. - **Parity surface:** auth, retries with `Retry-After` + jittered backoff, per-attempt curl timeouts, middleware callables, pagination (`Pages()` / `Items()` as `\Generator`s), SSE (`iterSse` over a curl_multi pump), multipart. -- The runtime is hand-written in `runtime/php/runtime.php` (`php -l`-clean) and embedded +- The runtime is hand-written in `runtime/runtime.php` in this folder (`php -l`-clean) and embedded at prepare time. `curl_close` is never called (deprecated since PHP 8.5, no-op since 8.0). - Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. diff --git a/tests/e2e/generate-client/module-runtime.test.ts b/tests/e2e/generate-client/module-runtime.test.ts new file mode 100644 index 0000000000..eb31e4a1b5 --- /dev/null +++ b/tests/e2e/generate-client/module-runtime.test.ts @@ -0,0 +1,78 @@ +import { 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 { generateInto, killServer, runConsumer, startServer, strictTypecheck } from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixture = join(__dirname, 'fixtures/cafe.yaml'); +const serverScript = join(__dirname, 'cafe-consumer/server.ts'); + +const SERVER_PORT = 3113; +const SERVER_BASE = `http://127.0.0.1:${SERVER_PORT}`; + +describe('generate-client --runtime module (end-to-end)', () => { + let serverProcess: ChildProcess | undefined; + let dir = ''; + + beforeAll(async () => { + serverProcess = await startServer( + serverScript, + join(__dirname, 'cafe-consumer'), + { CAFE_SERVER_PORT: String(SERVER_PORT) }, + SERVER_BASE, + 'module-runtime-server' + ); + dir = mkdtempSync(join(tmpdir(), 'module-runtime-')); + generateInto(dir, fixture, ['--runtime', 'module', '--server-url', SERVER_BASE]); + }, 120_000); + + afterAll(async () => { + if (serverProcess) await killServer(serverProcess); + rmSync(dir, { recursive: true, force: true }); + }); + + it('writes the per-needs runtime files and a client that imports them relatively', () => { + // cafe needs auth — its multipart body is untyped (binary upload), no SSE, + // no pagination, no setup — so only the core modules + auth are written. + for (const name of [ + 'types', + 'errors', + 'url', + 'parse', + 'retry', + 'auth', + 'send', + 'create-client', + 'factory', + ]) { + expect(existsSync(join(dir, 'runtime', `${name}.ts`)), name).toBe(true); + } + for (const name of ['multipart', 'sse', 'paginate', 'setup']) { + expect(existsSync(join(dir, 'runtime', `${name}.ts`)), name).toBe(false); + } + const entry = readFileSync(join(dir, 'client.ts'), 'utf-8'); + expect(entry).toContain("import { createClient } from './runtime/factory.js';"); + expect(entry).toContain("export * from './runtime/factory.js';"); + expect(entry).not.toContain('// ─── Embedded runtime'); + }); + + it('the client + runtime folder type-check strictly together', () => { + strictTypecheck(dir); + }, 120_000); + + it('a real request goes through the runtime files and returns typed data', () => { + const results = runConsumer( + dir, + `import { ApiError, listMenuItems } from './client.js'; +const items = await listMenuItems({}); +console.log(JSON.stringify({ ok: Array.isArray(items.items), viaApiError: typeof ApiError })); +` + ) as { ok: boolean; viaApiError: string }; + expect(results.ok).toBe(true); + // The error class reaches the consumer through the factory re-export chain. + expect(results.viaApiError).toBe('function'); + }, 120_000); +}); From 1f02b233683b369c29191bbfef0a3043f32b6d2e Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 23 Aug 2026 00:12:16 +0300 Subject: [PATCH 31/35] feat: runtime 'module' writes the python, go, and php runtimes as real files beside their clients --- docs/@v2/commands/generate-client.md | 8 +- .../eject-assets/skills/go-generator/SKILL.md | 2 + .../skills/php-generator/SKILL.md | 2 + .../skills/python-generator/SKILL.md | 2 + .../src/generators/__tests__/go.test.ts | 20 +++ .../src/generators/__tests__/php.test.ts | 18 +++ .../src/generators/__tests__/python.test.ts | 19 +++ .../src/generators/go/AGENTS.md | 2 + .../src/generators/go/index.ts | 150 ++++++++++++------ .../client-generator/src/generators/meta.ts | 11 -- .../src/generators/php/AGENTS.md | 2 + .../src/generators/php/index.ts | 29 +++- .../src/generators/python/AGENTS.md | 2 + .../src/generators/python/index.ts | 60 +++++-- .../.claude/skills/php-generator/SKILL.md | 2 + .../generate-client/module-runtime.test.ts | 79 ++++++++- 16 files changed, 321 insertions(+), 87 deletions(-) diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index c22015972f..bed6263d9d 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -150,8 +150,12 @@ The `--runtime` flag controls where the client engine lives: Several generated clients in one repository can share one `runtime/` folder, and you can read the engine as ordinary source files. The files are still machine-owned: the command regenerates them on every run. -The `typescript` and `cli` generators support `module`. -The `python`, `go`, and `php` generators support only `inline` for now. +Every generator that embeds an engine supports both modes, each in its language's shape: + +- `typescript` and `cli` write `runtime/*.ts` modules. +- `python` writes the `_*.py` runtime modules beside the client, which imports them. +- `go` writes a `runtime.go` file in the same package as the client. +- `php` writes a `runtime.php` file that the client loads with `require_once`. ```bash redocly generate-client openapi.yaml -o src/api/client.ts --runtime module diff --git a/packages/client-generator/eject-assets/skills/go-generator/SKILL.md b/packages/client-generator/eject-assets/skills/go-generator/SKILL.md index 6bcd2becf2..b46cbd1df9 100644 --- a/packages/client-generator/eject-assets/skills/go-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/go-generator/SKILL.md @@ -74,6 +74,8 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies. large-description scale. - The runtime is hand-written in `runtime/runtime.go` in this folder (gofmt-clean, `go vet`-clean) and embedded at prepare time. + Under `--runtime module` it is written as a same-package `runtime.go` beside the client, + whose import block then lists only the packages its own body uses. - Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. - **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes diff --git a/packages/client-generator/eject-assets/skills/php-generator/SKILL.md b/packages/client-generator/eject-assets/skills/php-generator/SKILL.md index 6ffbc4cfd5..a280503ed8 100644 --- a/packages/client-generator/eject-assets/skills/php-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/php-generator/SKILL.md @@ -76,6 +76,8 @@ $idempotencyKey` on mutating methods. `\Generator`s), SSE (`iterSse` over a curl_multi pump), multipart. - The runtime is hand-written in `runtime/runtime.php` in this folder (`php -l`-clean) and embedded at prepare time. `curl_close` is never called (deprecated since PHP 8.5, no-op since 8.0). + Under `--runtime module` it is written as a `runtime.php` the client `require_once`s, + with its namespace rewritten to the client's so one namespace spans both files. - Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. ## Migrating from a service-based SDK diff --git a/packages/client-generator/eject-assets/skills/python-generator/SKILL.md b/packages/client-generator/eject-assets/skills/python-generator/SKILL.md index 9c681832b4..79ea675bf2 100644 --- a/packages/client-generator/eject-assets/skills/python-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/python-generator/SKILL.md @@ -87,6 +87,8 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a `_items()` + `aiter` mirrors), SSE (`iter_sse`/`aiter_sse`), multipart. - The runtime is hand-written in `runtime/*.py` in this folder and embedded as strings at prepare time — generator code never builds runtime logic from templates. + Under `--runtime module` the same sources are written as sibling `_*.py` files instead + (package-relative imports become sibling imports; the client star-imports each module). - Authored ONLY with the neutral toolkit (`Printer`, naming, schema, pagination helpers) — the dogfooding guard fails otherwise. diff --git a/packages/client-generator/src/generators/__tests__/go.test.ts b/packages/client-generator/src/generators/__tests__/go.test.ts index b242498a16..d0a01acc11 100644 --- a/packages/client-generator/src/generators/__tests__/go.test.ts +++ b/packages/client-generator/src/generators/__tests__/go.test.ts @@ -421,6 +421,26 @@ describe('query and sample shapes', () => { }); }); +describe('goGenerator runtime: module', () => { + it('writes runtime.go in the same package and prunes the client imports to its own uses', () => { + const files = goGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { runtime: 'module' }, + }); + const runtime = files.find((file) => file.path === '/out/runtime.go')!.content; + expect(runtime).toContain('\npackage client\n'); + expect(runtime.startsWith('// Generated by @redocly/client-generator')).toBe(true); + const entry = files.find((file) => file.path === '/out/client.go')!.content; + expect(entry).not.toContain('// ─── Embedded runtime'); + // Retry backoff is runtime machinery: an unused import is a Go compile error, + // so the client's import block must not carry it. + expect(entry).not.toContain('"math/rand"'); + expect(entry).toContain('"encoding/json"'); + }); +}); + describe('goGenerator parity features', () => { it('paginated operations gain Pages/Items yield-func iterators with typed elements', () => { const out = generateGo(); diff --git a/packages/client-generator/src/generators/__tests__/php.test.ts b/packages/client-generator/src/generators/__tests__/php.test.ts index 0acdc1ffc2..81d45215d0 100644 --- a/packages/client-generator/src/generators/__tests__/php.test.ts +++ b/packages/client-generator/src/generators/__tests__/php.test.ts @@ -571,6 +571,24 @@ describe('method names are unique across the client', () => { }); }); +describe('phpGenerator runtime: module', () => { + it('requires runtime.php beside the client and rewrites its namespace to match', () => { + const files = phpGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { runtime: 'module' }, + }); + const entry = files.find((file) => file.path === '/out/client.php')!.content; + expect(entry).toContain("require_once __DIR__ . '/runtime.php';"); + expect(entry).not.toContain('// ─── Embedded runtime'); + const runtime = files.find((file) => file.path === '/out/runtime.php')!.content; + expect(runtime).toContain('namespace CafeOrdersApi;'); + expect(runtime).not.toContain('namespace RedoclyClientRuntime;'); + expect(runtime).toContain('// Generated by @redocly/client-generator'); + }); +}); + describe('phpGenerator (full client assembly)', () => { it('assembles one runnable file: namespace, models, embedded runtime, operations, Client', () => { const out = generatePhp(); diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts index aacba0616f..863d586180 100644 --- a/packages/client-generator/src/generators/__tests__/python.test.ts +++ b/packages/client-generator/src/generators/__tests__/python.test.ts @@ -426,6 +426,25 @@ function generate(errorMode: 'throw' | 'result' = 'throw'): string { return files[0].content; } +describe('pythonGenerator runtime: module', () => { + it('writes the runtime as sibling modules and star-imports them instead of embedding', () => { + const files = pythonGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { runtime: 'module' }, + }); + const entry = files.find((file) => file.path === '/out/client.py')!.content; + expect(entry).toContain('from _send import *'); + expect(entry).not.toContain('# ─── Embedded runtime'); + // The flat sibling layout has no package, so the intra-runtime imports drop the dot. + const send = files.find((file) => file.path === '/out/_send.py')!.content; + expect(send).toContain('from _errors import'); + expect(send).not.toContain('from ._'); + expect(send.startsWith('# Generated by @redocly/client-generator')).toBe(true); + }); +}); + describe('python auth keys', () => { it('accepts apiKey (the documented, cross-language key) and api_key alike', () => { if (!hasHttpx) return; diff --git a/packages/client-generator/src/generators/go/AGENTS.md b/packages/client-generator/src/generators/go/AGENTS.md index fd53b99863..6cdf8a4f8f 100644 --- a/packages/client-generator/src/generators/go/AGENTS.md +++ b/packages/client-generator/src/generators/go/AGENTS.md @@ -73,6 +73,8 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies. large-description scale. - The runtime is hand-written in `runtime/runtime.go` in this folder (gofmt-clean, `go vet`-clean) and embedded at prepare time. + Under `--runtime module` it is written as a same-package `runtime.go` beside the client, + whose import block then lists only the packages its own body uses. - Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. - **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index 7ba12a02c5..d56a545c57 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -6,7 +6,10 @@ // One file per pipeline stage (ADR-0020); this entry assembles them. import { + type ApiModel, type CodeSample, + type DateType, + type EmitOptions, type Generator, identifierFor, jsonSuccessSchema, @@ -52,58 +55,45 @@ function stripHeader(source: string): string { return out.join('\n').trim(); } -/** The whole generated file: models + embedded runtime + operations table + Client. */ -export const goGenerator: Generator = ({ model, output, emit, pagination }) => { - const printer = new GoPrinter(); - const dateType = emit.dateType ?? 'string'; - const packageName = goPackageName(emit.goPackage); - // Pagination arrives RESOLVED from the pipeline — one fit-verified answer per run. - const paginationRules = new Map(); - for (const { op, ident } of goOperationIdents(model)) { - const spec = pagination?.get(op.name)?.spec; - if (spec !== undefined) paginationRules.set(ident, spec); - } - printer.line( - `// Code generated by @redocly/client-generator (go) from "${model.title}" ${model.version}. DO NOT EDIT.` - ); - printer.line( - '// Regenerate with `redocly generate-client`. Standard library only — zero dependencies.' - ); - printer.line(`package ${packageName}`); - printer.blank(); - // 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(naming.string(spec)); - } - }, - ')' - ); - printer.blank(); +/** Every stdlib package the merged inline file needs (the runtime dominates the list). */ +const GO_STDLIB_IMPORTS = [ + 'bytes', + 'context', + 'encoding/base64', + 'encoding/json', + 'errors', + 'fmt', + 'io', + 'math/rand', + 'mime/multipart', + 'net/http', + 'net/url', + 'strconv', + 'strings', + 'time', +]; +/** + * Everything below the import block: models, servers, the (optionally embedded) + * runtime, the operations table, and the Client — one emission path for both + * runtime modes, so module mode cannot drift from the inline layout. + */ +function writeGoBody( + printer: GoPrinter, + model: ApiModel, + emit: EmitOptions, + dateType: DateType, + paginationRules: Map, + embedRuntime: boolean +): void { printer.line(stripHeader(renderGoModels(model, dateType))); printer.blank(); writeGoServers(printer, model); - printer.line('// ─── Embedded runtime (@redocly/client-generator go runtime) ───'); - printer.line(stripHeader(GO_RUNTIME_SOURCE)); - printer.blank(); + if (embedRuntime) { + printer.line('// ─── Embedded runtime (@redocly/client-generator go runtime) ───'); + printer.line(stripHeader(GO_RUNTIME_SOURCE)); + printer.blank(); + } printer.block( 'type operationMeta struct {', @@ -202,13 +192,69 @@ export const goGenerator: Generator = ({ model, output, emit, pagination }) => { element === undefined ? 'any' : goType(element, dateType) ); } +} + +/** The whole generated file: models + embedded runtime + operations table + Client. */ +export const goGenerator: Generator = ({ model, output, banner, emit, pagination }) => { + const printer = new GoPrinter(); + const dateType = emit.dateType ?? 'string'; + const packageName = goPackageName(emit.goPackage); + // Pagination arrives RESOLVED from the pipeline — one fit-verified answer per run. + const paginationRules = new Map(); + for (const { op, ident } of goOperationIdents(model)) { + const spec = pagination?.get(op.name)?.spec; + if (spec !== undefined) paginationRules.set(ident, spec); + } + printer.line( + `// Code generated by @redocly/client-generator (go) from "${model.title}" ${model.version}. DO NOT EDIT.` + ); + printer.line( + '// Regenerate with `redocly generate-client`. Standard library only — zero dependencies.' + ); + printer.line(`package ${packageName}`); + printer.blank(); + const embedRuntime = emit.runtime !== 'module'; + // One merged import block. Inline: the runtime uses every entry. Module: the runtime + // imports for itself, so the client lists only the packages its own body references — + // an unused import is a Go compile error, so the subset is derived from the body text. + const imports = embedRuntime + ? GO_STDLIB_IMPORTS + : (() => { + const scratch = new GoPrinter(); + writeGoBody(scratch, model, emit, dateType, paginationRules, false); + const body = scratch.toString(); + return GO_STDLIB_IMPORTS.filter((spec) => + new RegExp(`\\b${spec.split('/').pop()}\\.`).test(body) + ); + })(); + printer.block( + 'import (', + () => { + for (const spec of imports) { + printer.line(naming.string(spec)); + } + }, + ')' + ); + printer.blank(); + writeGoBody(printer, model, emit, dateType, paginationRules, embedRuntime); + + const entry = { + path: output.path.replace(/\.[^.\\/]+$/, '.go'), + // Sections are stitched with their own trailing blanks; gofmt allows at most one + // between declarations and none at the end of the file. + content: printer.toString(), + }; + if (embedRuntime) return [entry]; + // The runtime, verbatim except the package clause — same directory, same Go package. + const header = banner.map((line) => `// ${line}`).join('\n'); + const runtimeSource = GO_RUNTIME_SOURCE.replace(/^package .*$/m, `package ${packageName}`); return [ + entry, { - path: output.path.replace(/\.[^.\\/]+$/, '.go'), - // Sections are stitched with their own trailing blanks; gofmt allows at most one - // between declarations and none at the end of the file. - content: printer.toString(), + path: entry.path.replace(/[^\\/]+$/, 'runtime.go'), + content: `${header}\n${runtimeSource.trimEnd()}\n`, }, ]; }; diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index 622553d56f..6bdffe67c2 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -150,17 +150,6 @@ export function validateSelection( } const errorMode = emit.errorMode ?? 'throw'; const dateType = emit.dateType ?? 'string'; - // Module-runtime coverage grows generator by generator (ADR-0022); the languages - // whose emitters still embed only get a clear answer instead of inline output that - // silently ignored the choice. - if (emit.runtime === 'module') { - const inlineOnly = names.find((name) => ['python', 'go', 'php'].includes(name)); - if (inlineOnly !== undefined) { - throw new NotSupportedError( - `The "${inlineOnly}" generator supports only the inline runtime for now — drop --runtime module or generate it separately.` - ); - } - } for (const name of names) { const descriptor = registry.get(name); if (!descriptor) { diff --git a/packages/client-generator/src/generators/php/AGENTS.md b/packages/client-generator/src/generators/php/AGENTS.md index f91e9f6b72..bf808298ea 100644 --- a/packages/client-generator/src/generators/php/AGENTS.md +++ b/packages/client-generator/src/generators/php/AGENTS.md @@ -75,6 +75,8 @@ $idempotencyKey` on mutating methods. `\Generator`s), SSE (`iterSse` over a curl_multi pump), multipart. - The runtime is hand-written in `runtime/runtime.php` in this folder (`php -l`-clean) and embedded at prepare time. `curl_close` is never called (deprecated since PHP 8.5, no-op since 8.0). + Under `--runtime module` it is written as a `runtime.php` the client `require_once`s, + with its namespace rewritten to the client's so one namespace spans both files. - Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. ## Migrating from a service-based SDK diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index be22958be5..ad52a3bfa7 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -46,7 +46,7 @@ function stripPhpHeader(source: string): string { } /** The whole generated file: namespace + models + embedded runtime + operations + Client. */ -export const phpGenerator: Generator = ({ model, output, emit, pagination }) => { +export const phpGenerator: Generator = ({ model, output, banner, emit, pagination }) => { const printer = new PhpPrinter(); const dateType = emit.dateType ?? 'string'; const namespace = identifierFor(model.title, { style: 'pascal', reserved: PHP }); @@ -65,9 +65,17 @@ export const phpGenerator: Generator = ({ model, output, emit, pagination }) => printer.blank(); printer.line(renderPhpModels(model, dateType)); writeServers(printer, model); - printer.line('// ─── Embedded runtime (@redocly/client-generator php runtime) ───'); - printer.line(stripPhpHeader(PHP_RUNTIME_SOURCE)); - printer.blank(); + if (emit.runtime === 'module') { + // The runtime file re-declares this same namespace, so the require binds the + // exact names the inline stitching would have defined at this position. + printer.line('// ─── Runtime (a real file beside this one, written by the same run) ───'); + printer.line("require_once __DIR__ . '/runtime.php';"); + printer.blank(); + } else { + printer.line('// ─── Embedded runtime (@redocly/client-generator php runtime) ───'); + printer.line(stripPhpHeader(PHP_RUNTIME_SOURCE)); + printer.blank(); + } const operations = model.services.flatMap((service) => service.operations); const idents = methodIdents(model); @@ -153,7 +161,18 @@ export const phpGenerator: Generator = ({ model, output, emit, pagination }) => '}' ); - return [{ path: output.path.replace(/\.[^.\\/]+$/, '.php'), content: printer.toString() }]; + const entry = { path: output.path.replace(/\.[^.\\/]+$/, '.php'), content: printer.toString() }; + if (emit.runtime !== 'module') return [entry]; + // The runtime, verbatim except its namespace: rewritten to the client's, so one + // namespace spans both files and every bare reference resolves unchanged. + const header = banner.map((line) => `// ${line}`).join('\n'); + const runtimeSource = PHP_RUNTIME_SOURCE.replace(/^namespace .*$/m, `namespace ${namespace};`) + .replace(/^<\?php\n/, `.py`: typed dataclass models, a sync `Client` and an a `_items()` + `aiter` mirrors), SSE (`iter_sse`/`aiter_sse`), multipart. - The runtime is hand-written in `runtime/*.py` in this folder and embedded as strings at prepare time — generator code never builds runtime logic from templates. + Under `--runtime module` the same sources are written as sibling `_*.py` files instead + (package-relative imports become sibling imports; the client star-imports each module). - Authored ONLY with the neutral toolkit (`Printer`, naming, schema, pagination helpers) — the dogfooding guard fails otherwise. diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index 21f64400a8..0aa962b8f1 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -58,7 +58,14 @@ function pythonModulePath(outputPath: string): string { } /** The whole generated file: header, models, embedded runtime, descriptors, clients. */ -export const pythonGenerator: Generator = ({ model, output, emit, options, pagination }) => { +export const pythonGenerator: Generator = ({ + model, + output, + banner, + emit, + options, + pagination, +}) => { const errorMode = emit.errorMode ?? 'throw'; const dateType = emit.dateType ?? 'string'; const models = (options?.models as PythonModels | undefined) ?? 'dataclass'; @@ -81,20 +88,31 @@ export const pythonGenerator: Generator = ({ model, output, emit, options, pagin printer.blank(); writePythonServers(printer, model); - // The embedded runtime, stitched into one module: `from __future__` may appear - // only at the top of a file, and the intra-runtime relative imports resolve to - // this same file — both are dropped; duplicate stdlib imports are legal Python. - printer.line('# ─── Embedded runtime (@redocly/client-generator python runtime) ───'); - for (const source of Object.values(PYTHON_RUNTIME_SOURCES)) { - const stitched = source - .split('\n') - .filter((line) => !line.startsWith('from __future__') && !line.startsWith('from ._')) - .join('\n') - .trim(); - printer.line(stitched); + if (emit.runtime === 'module') { + // The runtime lives in real sibling modules; star imports rebind the same + // public names the inline stitching would have defined at this position. + printer.line('# ─── Runtime (real modules beside this file, written by the same run) ───'); + for (const name of Object.keys(PYTHON_RUNTIME_SOURCES)) { + printer.line(`from ${name.replace(/\.py$/, '')} import *`); + } + printer.blank(); + printer.blank(); + } else { + // The embedded runtime, stitched into one module: `from __future__` may appear + // only at the top of a file, and the intra-runtime relative imports resolve to + // this same file — both are dropped; duplicate stdlib imports are legal Python. + printer.line('# ─── Embedded runtime (@redocly/client-generator python runtime) ───'); + for (const source of Object.values(PYTHON_RUNTIME_SOURCES)) { + const stitched = source + .split('\n') + .filter((line) => !line.startsWith('from __future__') && !line.startsWith('from ._')) + .join('\n') + .trim(); + printer.line(stitched); + printer.blank(); + } printer.blank(); } - printer.blank(); const registrations = discriminatorRegistrations(model, new Set(pydantic?.unions.keys())); if (registrations.length > 0) { printer.line('# Discriminated unions dispatch by their property inside decode().'); @@ -142,7 +160,21 @@ export const pythonGenerator: Generator = ({ model, output, emit, options, pagin writeClientClass(printer, model, errorMode, false, paginationSpecs, serverUrl, dateType); writeClientClass(printer, model, errorMode, true, paginationSpecs, serverUrl, dateType); - return [{ path: pythonModulePath(output.path), content: printer.toString() }]; + const entry = { path: pythonModulePath(output.path), content: printer.toString() }; + if (emit.runtime !== 'module') return [entry]; + // The runtime modules, verbatim except the package-relative imports: the flat + // sibling layout has no package, so `from ._x` becomes the sibling `from _x`. + const header = banner.map((line) => `# ${line}`).join('\n'); + const dir = pythonModulePath(output.path).replace(/[^\\/]+$/, ''); + const runtimeFiles = Object.entries(PYTHON_RUNTIME_SOURCES).map(([name, source]) => ({ + path: `${dir}${name}`, + content: `${header}\n\n${source + .split('\n') + .map((line) => (line.startsWith('from ._') ? line.replace('from ._', 'from _') : line)) + .join('\n') + .trim()}\n`, + })); + return [entry, ...runtimeFiles]; }; /** One idiomatic Python call per operation — feeds `x-codeSamples` for docs. */ 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 index 6ffbc4cfd5..a280503ed8 100644 --- 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 @@ -76,6 +76,8 @@ $idempotencyKey` on mutating methods. `\Generator`s), SSE (`iterSse` over a curl_multi pump), multipart. - The runtime is hand-written in `runtime/runtime.php` in this folder (`php -l`-clean) and embedded at prepare time. `curl_close` is never called (deprecated since PHP 8.5, no-op since 8.0). + Under `--runtime module` it is written as a `runtime.php` the client `require_once`s, + with its namespace rewritten to the client's so one namespace spans both files. - Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. ## Migrating from a service-based SDK diff --git a/tests/e2e/generate-client/module-runtime.test.ts b/tests/e2e/generate-client/module-runtime.test.ts index eb31e4a1b5..0e2fde115e 100644 --- a/tests/e2e/generate-client/module-runtime.test.ts +++ b/tests/e2e/generate-client/module-runtime.test.ts @@ -1,10 +1,21 @@ -import { type ChildProcess } from 'node:child_process'; -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { spawnSync, type ChildProcess } from 'node:child_process'; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { generateInto, killServer, runConsumer, startServer, strictTypecheck } from './helpers.js'; +import { + generate, + generateInto, + killServer, + runConsumer, + startServer, + strictTypecheck, +} from './helpers.js'; + +const hasPython = spawnSync('python3', ['--version']).status === 0; +const hasGo = spawnSync('go', ['version']).status === 0; +const hasPhp = spawnSync('php', ['--version']).status === 0; const __dirname = dirname(fileURLToPath(import.meta.url)); const fixture = join(__dirname, 'fixtures/cafe.yaml'); @@ -76,3 +87,65 @@ console.log(JSON.stringify({ ok: Array.isArray(items.items), viaApiError: typeof expect(results.viaApiError).toBe('function'); }, 120_000); }); + +describe('generate-client --runtime module — language generators', () => { + let dir = ''; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'module-runtime-lang-')); + for (const generator of ['python', 'go', 'php']) { + generate(fixture, join(dir, generator, 'client.ts'), [ + '--generator', + generator, + '--runtime', + 'module', + ]); + } + }, 120_000); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it.skipIf(!hasPython)('python: the client and every runtime module compile', () => { + const files = readdirSync(join(dir, 'python')).filter((name) => name.endsWith('.py')); + expect(files).toContain('_send.py'); + for (const name of files) { + const result = spawnSync('python3', ['-m', 'py_compile', join(dir, 'python', name)], { + encoding: 'utf-8', + }); + expect(result.status, `${name}: ${result.stderr}`).toBe(0); + } + expect(readFileSync(join(dir, 'python', 'client.py'), 'utf-8')).toContain( + 'from _send import *' + ); + }); + + it.skipIf(!hasGo)( + 'go: the client and runtime.go build as one package', + () => { + writeFileSync(join(dir, 'go', 'go.mod'), 'module smoke.test\n\ngo 1.21\n', 'utf-8'); + const result = spawnSync('go', ['build', './...'], { + cwd: join(dir, 'go'), + encoding: 'utf-8', + }); + expect(result.status, result.stderr).toBe(0); + }, + // A cold CI cache compiles the stdlib on the first build. + 180_000 + ); + + it.skipIf(!hasPhp)('php: both files parse and the client requires its runtime', () => { + for (const name of ['client.php', 'runtime.php']) { + const lint = spawnSync('php', ['-l', join(dir, 'php', name)], { encoding: 'utf-8' }); + expect(lint.status, lint.stdout + lint.stderr).toBe(0); + } + const declare = spawnSync( + 'php', + ['-r', `require '${join(dir, 'php', 'client.php')}'; echo 'DECLARED';`], + { encoding: 'utf-8' } + ); + expect(declare.status, declare.stdout + declare.stderr).toBe(0); + expect(declare.stdout).toContain('DECLARED'); + }); +}); From 818f4b5aee55c1157f15e2bf9a87234069334ff7 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 23 Aug 2026 00:49:06 +0300 Subject: [PATCH 32/35] =?UTF-8?q?feat:=20every=20generator=20ejects=20as?= =?UTF-8?q?=20its=20TypeScript=20source=20folder=20=E2=80=94=20emitters=20?= =?UTF-8?q?dissolved,=20ABI=20contracts=20at=20package=20level,=20one=20im?= =?UTF-8?q?port=20guard=20for=20all=20ten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/@v2/commands/eject-generator.md | 14 +- .../@v2/guides/customize-client-generation.md | 2 +- packages/cli/src/commands/eject-generator.ts | 2 +- .../skills/cli-generator/SKILL.md | 8 +- .../skills/mock-generator/SKILL.md | 8 +- .../skills/swr-generator/SKILL.md | 8 +- .../skills/tanstack-query-generator/SKILL.md | 8 +- .../skills/transformers-generator/SKILL.md | 8 +- .../skills/typescript-generator/SKILL.md | 8 +- .../skills/zod-generator/SKILL.md | 8 +- .../scripts/ejected-skill.d.mts | 2 +- .../scripts/ejected-skill.mjs | 9 +- .../scripts/generate-eject-assets.mjs | 114 +++---------- .../scripts/generate-runtime-sources.mjs | 16 +- .../src/{emitters => }/__tests__/fixtures.ts | 6 +- .../src/__tests__/pagination.test.ts | 9 +- .../src/__tests__/pipeline-ts-free.test.ts | 25 +-- .../__tests__/reserved-names.test.ts | 2 +- .../__tests__/setup-bake.test.ts | 0 .../{emitters => }/__tests__/ts-guard.test.ts | 0 .../client-generator/src/authoring/naming.ts | 2 +- packages/client-generator/src/cli-contract.ts | 142 ++++++++++++++++ .../contracts/__tests__/typescript.test.ts | 2 +- .../__tests__/generator-skills.test.ts | 48 +++--- .../__tests__/go-runtime-embed.test.ts | 2 +- .../__tests__/language-dogfooding.test.ts | 70 +++++--- .../src/generators/__tests__/mock.test.ts | 2 +- .../__tests__/php-runtime-embed.test.ts | 2 +- .../__tests__/python-runtime-embed.test.ts | 2 +- .../__tests__/runtime-embed-freshness.test.ts | 6 +- .../src/generators/__tests__/swr.test.ts | 2 +- .../__tests__/tanstack-query.test.ts | 2 +- .../generators/__tests__/transformers.test.ts | 2 +- .../src/generators/__tests__/zod.test.ts | 2 +- .../src/generators/cli/docs.ts | 9 +- .../src/generators/cli/engine-source.ts | 17 ++ .../src/generators/cli/index.ts | 16 +- .../src/generators/cli/render.ts | 28 ++-- .../src/generators/cli/runtime/cli.ts | 152 +++--------------- .../generators/mock/__tests__/render.test.ts | 2 +- .../src/generators/mock/faker.ts | 25 +-- .../src/generators/mock/index.ts | 4 +- .../src/generators/mock/render.ts | 20 ++- .../src/generators/mock/sample.ts | 15 +- .../src/generators/mock/values.ts | 2 +- .../generators/swr/__tests__/render.test.ts | 8 +- .../src/generators/swr/index.ts | 4 +- .../src/generators/swr/render.ts | 6 +- .../tanstack-query/__tests__/render.test.ts | 8 +- .../src/generators/tanstack-query/index.ts | 4 +- .../src/generators/tanstack-query/render.ts | 19 ++- .../src/generators/transformers/index.ts | 4 +- .../src/generators/transformers/render.ts | 8 +- .../typescript/__tests__/banner.test.ts | 2 +- .../__tests__/client-assembly.test.ts | 2 +- .../typescript/__tests__/descriptor.test.ts | 2 +- .../__tests__/inline-runtime.test.ts | 0 .../typescript/__tests__/operations.test.ts | 2 +- .../typescript/__tests__/type-guards.test.ts | 2 +- .../src/generators/typescript/banner.ts | 4 +- .../generators/typescript/client-assembly.ts | 31 ++-- .../src/generators/typescript/descriptor.ts | 23 +-- .../src/generators/typescript/index.ts | 14 +- .../typescript}/inline-runtime.ts | 12 +- .../typescript/operation-signature.ts | 4 +- .../generators/typescript/operation-types.ts | 2 +- .../generators/typescript/render-client.ts | 26 +-- .../generators/typescript/response-headers.ts | 16 +- .../generators/typescript/runtime/types.ts | 8 +- .../src/generators/typescript/ts-type.ts | 23 +-- .../src/generators/typescript/type-guards.ts | 6 +- .../generators/zod/__tests__/schemas.test.ts | 2 +- .../src/generators/zod/index.ts | 4 +- .../src/generators/zod/schemas.ts | 4 +- packages/client-generator/src/index.ts | 18 ++- .../sanitize-identifiers.ts | 2 +- packages/client-generator/src/pipeline.ts | 2 +- packages/client-generator/src/plugin.ts | 4 + .../src/{emitters => }/reserved-names.ts | 2 +- .../client-generator/src/runtime-contract.ts | 7 + .../client-generator/src/runtime-sources.ts | 14 +- .../__tests__/typescript.test.ts} | 22 +-- .../go.ts} | 0 .../php.ts} | 0 .../python.ts} | 0 .../typescript.ts} | 0 .../src/{emitters => }/setup-bake.ts | 2 +- tests/e2e/generate-client/eject.test.ts | 47 +++--- 88 files changed, 604 insertions(+), 599 deletions(-) rename packages/client-generator/src/{emitters => }/__tests__/fixtures.ts (92%) rename packages/client-generator/src/{emitters => }/__tests__/reserved-names.test.ts (99%) rename packages/client-generator/src/{emitters => }/__tests__/setup-bake.test.ts (100%) rename packages/client-generator/src/{emitters => }/__tests__/ts-guard.test.ts (100%) create mode 100644 packages/client-generator/src/cli-contract.ts create mode 100644 packages/client-generator/src/generators/cli/engine-source.ts rename packages/client-generator/src/{emitters => generators/typescript}/__tests__/inline-runtime.test.ts (100%) rename packages/client-generator/src/{emitters => generators/typescript}/inline-runtime.ts (93%) rename packages/client-generator/src/{emitters => }/reserved-names.ts (98%) rename packages/client-generator/src/{emitters/__tests__/runtime-sources.test.ts => runtime-sources/__tests__/typescript.test.ts} (73%) rename packages/client-generator/src/{emitters/go-runtime-sources.ts => runtime-sources/go.ts} (100%) rename packages/client-generator/src/{emitters/php-runtime-sources.ts => runtime-sources/php.ts} (100%) rename packages/client-generator/src/{emitters/python-runtime-sources.ts => runtime-sources/python.ts} (100%) rename packages/client-generator/src/{emitters/runtime-sources.ts => runtime-sources/typescript.ts} (100%) rename packages/client-generator/src/{emitters => }/setup-bake.ts (98%) diff --git a/docs/@v2/commands/eject-generator.md b/docs/@v2/commands/eject-generator.md index b1d01a9533..119d668551 100644 --- a/docs/@v2/commands/eject-generator.md +++ b/docs/@v2/commands/eject-generator.md @@ -37,14 +37,12 @@ redocly eject-generator php --force The eject operation writes the generator and its design: -- A language generator (`python`, `go`, `php`) ejects as `//` — its TypeScript source folder, exactly as it was written. - Each stage of the generator is one file (`naming.ts`, `types.ts`, `models.ts`, `descriptor.ts`, `operations.ts`, `pagination.ts`, `client.ts`), and `index.ts` is the entry. - Running a TypeScript generator uses Node's own type stripping, which requires Node 22.18, 23.6, or newer. -- A TypeScript-family generator ejects as one plain ESM file, `/.mjs`, bundled together with the shared modules that it uses. - The bundle is not minified, and a comment marks each source module. - - In both cases, the generator 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. +- Every generator ejects as `//` — its TypeScript source folder, exactly as it was written. + Each concern of the generator is one file, and `index.ts` is the entry. + Running an ejected generator uses Node's own type stripping, which requires Node 22.18, 23.6, or newer. + + The generator imports the authoring toolkit from `@redocly/client-generator`. + Some generators also import `logger` or `isPlainObject` from `@redocly/openapi-core`, which is a dependency of the toolkit; the command tells you when yours does. If your package manager does not hoist dependencies, add `@redocly/openapi-core` explicitly. - `.claude/skills/-generator/SKILL.md` is the design of the generator, written as an agent skill. diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index cc9fe4093f..ba6e0e22fa 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -77,7 +77,7 @@ See the [`baked-setup` example](https://github.com/Redocly/redocly-cli/tree/main The quickest method to get a customized generator is [`redocly eject-generator `](../commands/eject-generator.md). -The command copies any built-in generator into `./generators/` as editable source that you own — a language generator as its TypeScript folder, a TypeScript-family generator as one `.mjs` file. +The command copies any built-in generator into `./generators/` as its TypeScript source folder — editable source that you own. An ejected generator with no changes produces byte-identical output. In `client.generators`, the path to your copy replaces the built-in name. Because of this, `redocly generate-client` now runs your version. diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index 13d265728c..844b9325bb 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -646,7 +646,7 @@ export const handleEjectGenerator = async ({ `\nThe "${name}" generator is the "tanstack-query" generator with one argument changed.\n` + `Eject that one and set the framework in your copy's default export:\n\n` + ` redocly eject-generator tanstack-query\n` + - ` # then in generators/tanstack-query.mjs: run: tanstackQueryGenerator('${framework}')\n` + ` # then in generators/tanstack-query/index.ts: run: tanstackQueryGenerator('${framework}')\n` ); return; } diff --git a/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md b/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md index 5aa8ca6dcc..8b19084b4a 100644 --- a/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md @@ -1,13 +1,13 @@ --- name: cli-generator -description: Design of the ejected Redocly `cli` client generator. Read it, and update it, before changing generators/cli.mjs. +description: Design of the ejected Redocly `cli` client generator. Read it, and update it, before changing generators/cli/. --- # The `cli` generator — its skill -This file is the DESIGN of your ejected `cli` generator (`generators/cli.mjs`): +This file is the DESIGN of your ejected `cli` generator (`generators/cli/`): **to change the generator, edit this skill first, then make the code match it** — a diff -to `generators/cli.mjs` that has no covering sentence here is incomplete. +to `generators/cli/` that has no covering sentence here is incomplete. ## What it emits @@ -105,7 +105,7 @@ codes are a contract for scripts, so change them only deliberately. ## The modify loop 1. Edit this skill: state the new behavior or decision. -2. Make `generators/cli.mjs` match it. +2. Make `generators/cli/` match it. 3. Run `redocly generate-client` and inspect the `git diff` of the generated output — generated files are never hand-edited. diff --git a/packages/client-generator/eject-assets/skills/mock-generator/SKILL.md b/packages/client-generator/eject-assets/skills/mock-generator/SKILL.md index b46113901b..8b9f16d0fb 100644 --- a/packages/client-generator/eject-assets/skills/mock-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/mock-generator/SKILL.md @@ -1,13 +1,13 @@ --- name: mock-generator -description: Design of the ejected Redocly `mock` client generator. Read it, and update it, before changing generators/mock.mjs. +description: Design of the ejected Redocly `mock` client generator. Read it, and update it, before changing generators/mock/. --- # The `mock` generator — its skill -This file is the DESIGN of your ejected `mock` generator (`generators/mock.mjs`): +This file is the DESIGN of your ejected `mock` generator (`generators/mock/`): **to change the generator, edit this skill first, then make the code match it** — a diff -to `generators/mock.mjs` that has no covering sentence here is incomplete. +to `generators/mock/` that has no covering sentence here is incomplete. ## What it emits @@ -38,7 +38,7 @@ Change the data strategy, the handler shape, or the factory surface, and regener ## The modify loop 1. Edit this skill: state the new behavior or decision. -2. Make `generators/mock.mjs` match it. +2. Make `generators/mock/` match it. 3. Run `redocly generate-client` and inspect the `git diff` of the generated output — generated files are never hand-edited. diff --git a/packages/client-generator/eject-assets/skills/swr-generator/SKILL.md b/packages/client-generator/eject-assets/skills/swr-generator/SKILL.md index e0fc15fef2..a0f53b4a8b 100644 --- a/packages/client-generator/eject-assets/skills/swr-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/swr-generator/SKILL.md @@ -1,13 +1,13 @@ --- name: swr-generator -description: Design of the ejected Redocly `swr` client generator. Read it, and update it, before changing generators/swr.mjs. +description: Design of the ejected Redocly `swr` client generator. Read it, and update it, before changing generators/swr/. --- # The `swr` generator — its skill -This file is the DESIGN of your ejected `swr` generator (`generators/swr.mjs`): +This file is the DESIGN of your ejected `swr` generator (`generators/swr/`): **to change the generator, edit this skill first, then make the code match it** — a diff -to `generators/swr.mjs` that has no covering sentence here is incomplete. +to `generators/swr/` that has no covering sentence here is incomplete. ## What it emits @@ -37,7 +37,7 @@ Change the hook shape or the key strategy, and regenerate. ## The modify loop 1. Edit this skill: state the new behavior or decision. -2. Make `generators/swr.mjs` match it. +2. Make `generators/swr/` match it. 3. Run `redocly generate-client` and inspect the `git diff` of the generated output — generated files are never hand-edited. diff --git a/packages/client-generator/eject-assets/skills/tanstack-query-generator/SKILL.md b/packages/client-generator/eject-assets/skills/tanstack-query-generator/SKILL.md index 96f9cd4e80..87bf048a07 100644 --- a/packages/client-generator/eject-assets/skills/tanstack-query-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/tanstack-query-generator/SKILL.md @@ -1,13 +1,13 @@ --- name: tanstack-query-generator -description: Design of the ejected Redocly `tanstack-query` client generator. Read it, and update it, before changing generators/tanstack-query.mjs. +description: Design of the ejected Redocly `tanstack-query` client generator. Read it, and update it, before changing generators/tanstack-query/. --- # The `tanstack-query` generator — its skill -This file is the DESIGN of your ejected `tanstack-query` generator (`generators/tanstack-query.mjs`): +This file is the DESIGN of your ejected `tanstack-query` generator (`generators/tanstack-query/`): **to change the generator, edit this skill first, then make the code match it** — a diff -to `generators/tanstack-query.mjs` that has no covering sentence here is incomplete. +to `generators/tanstack-query/` that has no covering sentence here is incomplete. ## What it emits @@ -41,7 +41,7 @@ export (`tanstackQueryGenerator('react')`), so switch it to `'vue'`, `'svelte'`, ## The modify loop 1. Edit this skill: state the new behavior or decision. -2. Make `generators/tanstack-query.mjs` match it. +2. Make `generators/tanstack-query/` match it. 3. Run `redocly generate-client` and inspect the `git diff` of the generated output — generated files are never hand-edited. diff --git a/packages/client-generator/eject-assets/skills/transformers-generator/SKILL.md b/packages/client-generator/eject-assets/skills/transformers-generator/SKILL.md index 62c09908c7..15b0c137e0 100644 --- a/packages/client-generator/eject-assets/skills/transformers-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/transformers-generator/SKILL.md @@ -1,13 +1,13 @@ --- name: transformers-generator -description: Design of the ejected Redocly `transformers` client generator. Read it, and update it, before changing generators/transformers.mjs. +description: Design of the ejected Redocly `transformers` client generator. Read it, and update it, before changing generators/transformers/. --- # The `transformers` generator — its skill -This file is the DESIGN of your ejected `transformers` generator (`generators/transformers.mjs`): +This file is the DESIGN of your ejected `transformers` generator (`generators/transformers/`): **to change the generator, edit this skill first, then make the code match it** — a diff -to `generators/transformers.mjs` that has no covering sentence here is incomplete. +to `generators/transformers/` that has no covering sentence here is incomplete. ## What it emits @@ -36,7 +36,7 @@ uses — one small `.mjs` you own, importing `@redocly/client-generator` and ## The modify loop 1. Edit this skill: state the new behavior or decision. -2. Make `generators/transformers.mjs` match it. +2. Make `generators/transformers/` match it. 3. Run `redocly generate-client` and inspect the `git diff` of the generated output — generated files are never hand-edited. diff --git a/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md b/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md index bede4fa750..61fc349466 100644 --- a/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md @@ -1,13 +1,13 @@ --- name: typescript-generator -description: Design of the ejected Redocly `typescript` client generator. Read it, and update it, before changing generators/typescript.mjs. +description: Design of the ejected Redocly `typescript` client generator. Read it, and update it, before changing generators/typescript/. --- # The `typescript` generator — its skill -This file is the DESIGN of your ejected `typescript` generator (`generators/typescript.mjs`): +This file is the DESIGN of your ejected `typescript` generator (`generators/typescript/`): **to change the generator, edit this skill first, then make the code match it** — a diff -to `generators/typescript.mjs` that has no covering sentence here is incomplete. +to `generators/typescript/` that has no covering sentence here is incomplete. ## What it emits @@ -73,7 +73,7 @@ generation time. ## The modify loop 1. Edit this skill: state the new behavior or decision. -2. Make `generators/typescript.mjs` match it. +2. Make `generators/typescript/` match it. 3. Run `redocly generate-client` and inspect the `git diff` of the generated output — generated files are never hand-edited. diff --git a/packages/client-generator/eject-assets/skills/zod-generator/SKILL.md b/packages/client-generator/eject-assets/skills/zod-generator/SKILL.md index 4e6ea0e987..d14ccd074f 100644 --- a/packages/client-generator/eject-assets/skills/zod-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/zod-generator/SKILL.md @@ -1,13 +1,13 @@ --- name: zod-generator -description: Design of the ejected Redocly `zod` client generator. Read it, and update it, before changing generators/zod.mjs. +description: Design of the ejected Redocly `zod` client generator. Read it, and update it, before changing generators/zod/. --- # The `zod` generator — its skill -This file is the DESIGN of your ejected `zod` generator (`generators/zod.mjs`): +This file is the DESIGN of your ejected `zod` generator (`generators/zod/`): **to change the generator, edit this skill first, then make the code match it** — a diff -to `generators/zod.mjs` that has no covering sentence here is incomplete. +to `generators/zod/` that has no covering sentence here is incomplete. ## What it emits @@ -42,7 +42,7 @@ Change the schema shapes, the naming, or what gets a schema at all, and regenera ## The modify loop 1. Edit this skill: state the new behavior or decision. -2. Make `generators/zod.mjs` match it. +2. Make `generators/zod/` match it. 3. Run `redocly generate-client` and inspect the `git diff` of the generated output — generated files are never hand-edited. diff --git a/packages/client-generator/scripts/ejected-skill.d.mts b/packages/client-generator/scripts/ejected-skill.d.mts index 7b56a6e432..c8f75d743a 100644 --- a/packages/client-generator/scripts/ejected-skill.d.mts +++ b/packages/client-generator/scripts/ejected-skill.d.mts @@ -1 +1 @@ -export function ejectedSkill(source: string, name: string, options?: { folder?: boolean }): string; +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 index 21e22aa5d8..4b9e0f4261 100644 --- a/packages/client-generator/scripts/ejected-skill.mjs +++ b/packages/client-generator/scripts/ejected-skill.mjs @@ -2,13 +2,12 @@ // 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 copy is generators// (a -// language generator's source folder) or generators/.mjs (a bundled TypeScript -// generator), and their loop is edit → regenerate → diff. The design bullets in between ship +// rewrites those two parts for the user's world: their copy is the generator's source +// folder at generators//, and their loop is edit → regenerate → diff. The design bullets in between ship // unchanged, and both anchors are structural (the first `## ` heading and the final // `## The modify loop` section), so skills can grow without touching this transform. -export function ejectedSkill(source, name, { folder = false } = {}) { - const copy = folder ? `generators/${name}/` : `generators/${name}.mjs`; +export function ejectedSkill(source, name) { + const copy = `generators/${name}/`; const frontmatter = [ '---', `name: ${name}-generator`, diff --git a/packages/client-generator/scripts/generate-eject-assets.mjs b/packages/client-generator/scripts/generate-eject-assets.mjs index 6270b14e97..a7d5c7c035 100644 --- a/packages/client-generator/scripts/generate-eject-assets.mjs +++ b/packages/client-generator/scripts/generate-eject-assets.mjs @@ -1,5 +1,4 @@ import { build } from 'esbuild'; -import { spawnSync } from 'node:child_process'; import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -7,20 +6,11 @@ import ts from 'typescript'; import { ejectedSkill } from './ejected-skill.mjs'; -// Build the ejectable generator assets, which `redocly eject-generator ` copies -// into the user's repo verbatim. Two shapes, because the generators have two shapes: -// -// - A language generator is a self-contained FOLDER of TypeScript stage files, so it -// ships as that folder — source copied byte-for-byte (the source already imports the -// public package entries), runnable under Node's native type stripping. The user -// reads their own generator, exactly as we wrote it. -// - A TypeScript generator is a thin entry over shared modules, so it ships BUNDLED -// into one `.mjs` (esbuild, unminified, one module comment per source file). -// `@redocly/client-generator` and `@redocly/openapi-core` stay external — those are -// the two packages an ejected generator imports. -// -// Both get a provenance header and the `defineGenerator`-shaped default export the -// resolver loads. +// Build the ejectable generator assets: one source FOLDER per built-in generator, +// which `redocly eject-generator ` copies into the user's repo verbatim. Each +// stage file ships as the TypeScript we wrote — imports already pointing at the public +// package entries — with a provenance header per file (`--update` merges per file) and +// the resolver's default export appended to `index.ts`. const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); const { version } = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')); const outDir = join(pkgRoot, 'eject-assets', 'generators'); @@ -129,98 +119,38 @@ function defaultExport(name, { run, sample, options, docs }) { return `\nexport default {\n${fields.join('\n')}\n};\n`; } -/** Fail the build loudly — a broken asset would only surface in a user's repo. */ -function checkSyntax(outFile, name) { - const check = spawnSync(process.execPath, ['--check', outFile], { encoding: 'utf-8' }); - if (check.status !== 0) { - process.stderr.write(`eject asset ${name}.mjs failed node --check:\n${check.stderr}`); - process.exit(1); - } -} - /** The generator's design, rewritten for the user's repo and shipped as an agent skill. */ -function writeSkill(name, options) { +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, options) - ); + writeFileSync(join(skillsDir, `${name}-generator`, 'SKILL.md'), ejectedSkill(skill, name)); } -const LANGUAGE = [ +/** + * Every built-in, with the expression that produces each one's `run`. The + * tanstack-query variants share one folder: the framework is a single argument in the + * ejected entry, so the copy is the place to change it rather than four near-identical + * folders. + */ +const GENERATORS = [ { name: 'python', run: 'pythonGenerator', sample: 'pythonSample', docs: 'pythonDocs' }, { name: 'go', run: 'goGenerator', sample: 'goSample', docs: 'goDocs' }, { name: 'php', run: 'phpGenerator', sample: 'phpSample', docs: 'phpDocs' }, -]; - -/** - * The TypeScript generators, with the expression that produces each one's `run`. The - * tanstack-query variants share this bundle: the framework is one argument, so the - * ejected copy is the place to change it rather than four near-identical files. - */ -const TYPESCRIPT = [ { name: 'typescript', - imports: ['typescriptGenerator', 'typescriptSample', 'typescriptDocs'], run: 'typescriptGenerator', sample: 'typescriptSample', docs: 'typescriptDocs', }, - { name: 'zod', imports: ['zodGenerator'], run: 'zodGenerator' }, - { name: 'mock', imports: ['mockGenerator'], run: 'mockGenerator' }, - { name: 'swr', imports: ['swrGenerator'], run: 'swrGenerator' }, - { name: 'transformers', imports: ['transformersGenerator'], run: 'transformersGenerator' }, - { - name: 'cli', - imports: ['cliGenerator', 'cliSample', 'cliDocs'], - run: 'cliGenerator', - sample: 'cliSample', - docs: 'cliDocs', - }, - { - name: 'tanstack-query', - imports: ['tanstackQueryGenerator'], - run: "tanstackQueryGenerator('react')", - }, + { name: 'zod', run: 'zodGenerator' }, + { name: 'mock', run: 'mockGenerator' }, + { name: 'swr', run: 'swrGenerator' }, + { name: 'transformers', run: 'transformersGenerator' }, + { name: 'cli', run: 'cliGenerator', sample: 'cliSample', docs: 'cliDocs' }, + { name: 'tanstack-query', run: "tanstackQueryGenerator('react')" }, ]; -for (const { name, imports, run, sample, options, docs } of TYPESCRIPT) { - // Bundling starts from a generated entry so the default export survives esbuild's - // renaming: appending it to the bundle would reference a symbol esbuild may have - // renamed, while an entry module's own export is resolved before that happens. - const entry = join(pkgRoot, 'eject-assets', `.entry-${name}.mjs`); - writeFileSync( - entry, - `import { ${imports.join(', ')} } from ${JSON.stringify( - join(pkgRoot, 'src', 'generators', name, 'index.ts') - )};\n` + defaultExport(name, { run, sample, options, docs }) - ); - const outFile = join(outDir, `${name}.mjs`); - try { - await build({ - entryPoints: [entry], - outfile: outFile, - bundle: true, - format: 'esm', - platform: 'node', - target: 'node20', - keepNames: true, - // Readable output: a user owns this file, so no minification and one comment - // per source module. - minify: false, - external: ['@redocly/client-generator', '@redocly/openapi-core'], - banner: { js: provenanceHeader(name) }, - logLevel: 'warning', - }); - } finally { - rmSync(entry, { force: true }); - } - checkSyntax(outFile, name); - writeSkill(name); -} - -for (const { name, run, sample, docs } of LANGUAGE) { +for (const { name, run, sample, docs } of GENERATORS) { const sourceDir = join(pkgRoot, 'src', 'generators', name); const assetDir = join(outDir, name); mkdirSync(assetDir, { recursive: true }); @@ -242,5 +172,5 @@ for (const { name, run, sample, docs } of LANGUAGE) { } writeFileSync(join(assetDir, file), content); } - writeSkill(name, { folder: true }); + writeSkill(name); } diff --git a/packages/client-generator/scripts/generate-runtime-sources.mjs b/packages/client-generator/scripts/generate-runtime-sources.mjs index c6914ed177..c8320bdc3e 100644 --- a/packages/client-generator/scripts/generate-runtime-sources.mjs +++ b/packages/client-generator/scripts/generate-runtime-sources.mjs @@ -32,13 +32,14 @@ const MODULES = [ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); const runtimeDir = join(pkgRoot, 'src', 'generators', 'typescript', 'runtime'); -const outFile = join(pkgRoot, 'src', 'emitters', 'runtime-sources.ts'); +const outFile = join(pkgRoot, 'src', 'runtime-sources', 'typescript.ts'); // The package-level modules whose type declarations the embed splices back in, keyed by // the specifier the runtime imports them with. const CONTRACT_MODULES = { '../../../runtime-contract.js': join(pkgRoot, 'src', 'runtime-contract.ts'), '../../../pagination.js': join(pkgRoot, 'src', 'pagination.ts'), + '../../../cli-contract.js': join(pkgRoot, 'src', 'cli-contract.ts'), }; /** The declaration's start including its own doc comment, excluding detached trivia. */ @@ -59,7 +60,10 @@ function contractDeclarationsText(modulePath, names) { const wanted = new Set(names); const parts = []; for (const statement of file.statements) { - if (ts.isTypeAliasDeclaration(statement) && wanted.has(statement.name.text)) { + const named = + (ts.isTypeAliasDeclaration(statement) || ts.isFunctionDeclaration(statement)) && + statement.name !== undefined; + if (named && wanted.has(statement.name.text)) { parts.push(source.slice(declStartWithDocs(source, statement), statement.end)); wanted.delete(statement.name.text); } @@ -175,7 +179,7 @@ const PYTHON_MODULES = [ '_multipart', ]; const pythonDir = join(pkgRoot, 'src', 'generators', 'python', 'runtime'); -const pythonOut = join(pkgRoot, 'src', 'emitters', 'python-runtime-sources.ts'); +const pythonOut = join(pkgRoot, 'src', 'runtime-sources', 'python.ts'); const pythonEntries = PYTHON_MODULES.map((name) => { const source = readFileSync(join(pythonDir, `${name}.py`), 'utf-8'); const line = ` '${name}.py': ${toStringLiteral(source)},`; @@ -196,7 +200,7 @@ writeFileSync( // The Go runtime embeds the same way (a single stdlib-only module). const goDir = join(pkgRoot, 'src', 'generators', 'go', 'runtime'); -const goOut = join(pkgRoot, 'src', 'emitters', 'go-runtime-sources.ts'); +const goOut = join(pkgRoot, 'src', 'runtime-sources', 'go.ts'); const goSource = readFileSync(join(goDir, 'runtime.go'), 'utf-8'); writeFileSync( goOut, @@ -210,7 +214,7 @@ writeFileSync( // The PHP runtime embeds the same way (a single curl-only module). const phpDir = join(pkgRoot, 'src', 'generators', 'php', 'runtime'); -const phpOut = join(pkgRoot, 'src', 'emitters', 'php-runtime-sources.ts'); +const phpOut = join(pkgRoot, 'src', 'runtime-sources', 'php.ts'); const phpSource = readFileSync(join(phpDir, 'runtime.php'), 'utf-8'); writeFileSync( phpOut, @@ -222,7 +226,7 @@ writeFileSync( ].join('\n') ); -// Stripped variants for inline embedding (emitters/inline-runtime.ts): imports dropped, +// Stripped variants for inline embedding (generators/typescript/inline-runtime.ts): imports dropped, // `export` removed except on the kept surface — done HERE at prepare time so the embed // path needs no TypeScript at generate time. Slices are AST-position-driven (no regexes), // so comments and formatting survive byte-for-byte. diff --git a/packages/client-generator/src/emitters/__tests__/fixtures.ts b/packages/client-generator/src/__tests__/fixtures.ts similarity index 92% rename from packages/client-generator/src/emitters/__tests__/fixtures.ts rename to packages/client-generator/src/__tests__/fixtures.ts index 555c4e7147..400270a11a 100644 --- a/packages/client-generator/src/emitters/__tests__/fixtures.ts +++ b/packages/client-generator/src/__tests__/fixtures.ts @@ -1,5 +1,5 @@ -import { emitClientSingleFile } from '../../generators/typescript/client-assembly.js'; -import { sseFromResponses } from '../../intermediate-representation/build.js'; +import { emitClientSingleFile } from '../generators/typescript/client-assembly.js'; +import { sseFromResponses } from '../intermediate-representation/build.js'; import type { ApiModel, NamedSchemaModel, @@ -7,7 +7,7 @@ import type { ParamModel, ResponseBodyModel, SchemaModel, -} from '../../intermediate-representation/model.js'; +} from '../intermediate-representation/model.js'; /** A plain `string` scalar — the default schema for params and the most-reused leaf. */ export const SCALAR: SchemaModel = { kind: 'scalar', scalar: 'string' }; diff --git a/packages/client-generator/src/__tests__/pagination.test.ts b/packages/client-generator/src/__tests__/pagination.test.ts index 1e3b805156..95778e46b3 100644 --- a/packages/client-generator/src/__tests__/pagination.test.ts +++ b/packages/client-generator/src/__tests__/pagination.test.ts @@ -1,11 +1,3 @@ -import { - apiModel, - namedSchema, - operation, - param, - response, - SCALAR, -} from '../emitters/__tests__/fixtures.js'; import type { ApiModel, OperationModel, @@ -17,6 +9,7 @@ import { resolveOperationPagination, resolveSchemaPointer, } from '../pagination.js'; +import { apiModel, namedSchema, operation, param, response, SCALAR } from './fixtures.js'; const ORDER: SchemaModel = { kind: 'object', diff --git a/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts b/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts index 37b49801c6..25e6b9a5e3 100644 --- a/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts +++ b/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts @@ -36,28 +36,15 @@ function staticGraph(entry: string): { files: Set; externals: Set { - it('statically loads no typescript and only the pure emitter helpers', () => { + it('statically loads no typescript and no generator folder', () => { const { files, externals } = staticGraph(join(libDir, 'pipeline.js')); expect(externals.has('typescript')).toBe(false); - const emitterFiles = [...files] - .filter((file) => /\/emitters\//.test(file)) - .map((file) => file.split('/emitters/')[1]) - .filter((name) => !PURE_EMITTER_HELPERS.has(name)); - expect(emitterFiles).toEqual([]); + // Built-ins are reached only through the dynamic imports in generators/meta.js — + // a generator folder in the static graph would load every language's emit stack + // (and, for the TS family, its printers) on every pipeline start. + const generatorFiles = [...files].filter((file) => /\/generators\/[a-z-]+\//.test(file)); + expect(generatorFiles).toEqual([]); }); }); diff --git a/packages/client-generator/src/emitters/__tests__/reserved-names.test.ts b/packages/client-generator/src/__tests__/reserved-names.test.ts similarity index 99% rename from packages/client-generator/src/emitters/__tests__/reserved-names.test.ts rename to packages/client-generator/src/__tests__/reserved-names.test.ts index 6311c7987a..ffbd7f4f56 100644 --- a/packages/client-generator/src/emitters/__tests__/reserved-names.test.ts +++ b/packages/client-generator/src/__tests__/reserved-names.test.ts @@ -1,7 +1,7 @@ import ts from 'typescript'; import { reservedModuleNames } from '../reserved-names.js'; -import { RUNTIME_SOURCES } from '../runtime-sources.js'; +import { RUNTIME_SOURCES } from '../runtime-sources/typescript.js'; /** * Every free identifier of a source — referenced but bound in no enclosing scope, so diff --git a/packages/client-generator/src/emitters/__tests__/setup-bake.test.ts b/packages/client-generator/src/__tests__/setup-bake.test.ts similarity index 100% rename from packages/client-generator/src/emitters/__tests__/setup-bake.test.ts rename to packages/client-generator/src/__tests__/setup-bake.test.ts diff --git a/packages/client-generator/src/emitters/__tests__/ts-guard.test.ts b/packages/client-generator/src/__tests__/ts-guard.test.ts similarity index 100% rename from packages/client-generator/src/emitters/__tests__/ts-guard.test.ts rename to packages/client-generator/src/__tests__/ts-guard.test.ts diff --git a/packages/client-generator/src/authoring/naming.ts b/packages/client-generator/src/authoring/naming.ts index 0621d68182..cc0b6df21a 100644 --- a/packages/client-generator/src/authoring/naming.ts +++ b/packages/client-generator/src/authoring/naming.ts @@ -1,6 +1,6 @@ // Language-neutral naming: one word splitter, four casings, and an identifier // sanitizer parameterized by the target language's reserved words. TypeScript -// keeps its specialized sanitizer in emitters/identifier.ts; this is for the +// keeps its specialized sanitizer in the TypeScript printer; this is for the // other output languages. /** Split on delimiters and camel/acronym boundaries: 'APIKey-v2' → ['api', 'key', 'v2']. */ diff --git a/packages/client-generator/src/cli-contract.ts b/packages/client-generator/src/cli-contract.ts new file mode 100644 index 0000000000..e042bf6671 --- /dev/null +++ b/packages/client-generator/src/cli-contract.ts @@ -0,0 +1,142 @@ +// The generated-CLI authoring contract: the command/wiring shapes a wrapper around a +// generated or composed CLI is written against, plus the two casing helpers the cli +// generator shares with the engine. Defined at package level (ADR-0022: contracts own +// their types); the engine re-exports them, and the prepare-time snapshot splices the +// definitions back into the embedded module so generated CLIs stay self-contained. + +/** One flag derived from a query parameter. */ +export type CliFlag = { + /** Kebab-cased flag name (`--page-size`). */ + name: string; + /** Original wire parameter name. */ + param: string; + type: 'string' | 'number' | 'boolean' | 'array'; + required: boolean; + enum?: string[]; + description?: string; +}; + +/** One executable command, derived from the IR at generate time. Pure data. */ +export type CliCommand = { + /** Tag; absent = flat/untagged. */ + group?: string; + name: string; + summary?: string; + method: string; + path: string; + /** Path params, in path-template order. Always required — that is what a path is. */ + positionals: Array<{ + name: string; + type?: CliFlag['type']; + description?: string; + }>; + flags: CliFlag[]; + /** + * Present when the operation takes a JSON request body. `merged` marks a body whose own + * properties a flat-style call spells at the top level (the generator decides this from + * the schema, so the CLI and the client can never disagree). + */ + body?: { required: boolean; merged?: boolean }; + /** + * The content type of a request body that is NOT JSON (multipart, url-encoded, binary). + * `--json` cannot build one, so the command is reported as library-only rather than + * offered as if it were runnable. + */ + unsupportedBody?: string; + paginated?: boolean; + /** `'grouped'` marks a command whose client method takes namespaced inputs even on a + * flat-style client, because its merged names would collide. */ + argsStyle?: 'grouped'; + sse?: boolean; + blob?: boolean; + /** IR schemas for the `schema` command, serialized verbatim. */ + schemas?: { request?: unknown; response?: unknown }; +}; + +export type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' }; + +export type CliWiring = { + /** The name the CLI is invoked as, for help output only. The generated entry reads it + * from `process.argv[1]`, so help never names a command that is not installed. */ + name: string; + /** Credential variable prefix, constant-cased: `CAFE` gives `CAFE_TOKEN`. Fixed at + * generation from the output file name, so renaming the binary keeps the variables + * a published CLI already documents. A composed entry sets one per api alias. */ + envPrefix: string; + /** The generated instance client. */ + client: Record; + /** How that client takes its inputs. Defaults to `'grouped'`, the generated default. */ + argsStyle?: 'grouped' | 'flat'; + configure: (config: Record) => void; + /** Security schemes of the API — drives env-var credential resolution. */ + schemes?: CliAuthScheme[]; + env?: Record; + stdin?: () => string; + readFile?: (path: string) => string; + writeFile?: (path: string, data: Uint8Array) => void; + stdout: (line: string) => void; + stderr: (line: string) => void; +}; + +export type CliGlobals = { + serverUrl?: string; + format?: 'json' | 'ndjson'; + dryRun?: boolean; + pageAll?: boolean; + output?: string; + token?: string; + json?: string; +}; + +/** + * A hand-written command composed NEXT TO the generated ones: the same data shape plus a + * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is + * how behavior that is not in the description (a `login`, a doctor command) joins the + * binary without the generator ever learning what it does. + */ +export type CustomCommand = { + name: string; + group?: string; + summary?: string; + positionals?: CliCommand['positionals']; + flags?: CliFlag[]; + /** Returns the process exit code; throwing exits 1 with the standard error JSON. */ + handler: (context: CommandContext) => number | Promise; +}; + +export type CommandContext = { + positionals: Record; + params: Record; + globals: CliGlobals; + wiring: CliWiring; +}; + +/** One API's contribution to a composed binary: its commands behind a namespace, with its + * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */ +export type CommandSource = { + namespace?: string; + commands: Array; + /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */ + wiring?: CliWiring; +}; + +/** + * The shell-typable form of a group name: an OpenAPI tag can contain spaces ("Some + * multi-word tag"), which only resolves if the user quotes it. Commands are addressed by + * this slug; help still shows the original tag. + */ +export function groupSlug(group: string): string { + return group + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean) + .join('-'); +} + +/** `cafe-api` → `CAFE_API`: the casing of every credential variable this CLI reads. */ +export function constantCase(value: string): string { + return value + .replace(/[^A-Za-z0-9]+/g, '_') + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toUpperCase(); +} diff --git a/packages/client-generator/src/contracts/__tests__/typescript.test.ts b/packages/client-generator/src/contracts/__tests__/typescript.test.ts index 3485e7f956..71bdea69cf 100644 --- a/packages/client-generator/src/contracts/__tests__/typescript.test.ts +++ b/packages/client-generator/src/contracts/__tests__/typescript.test.ts @@ -1,4 +1,4 @@ -import { operation, param } from '../../emitters/__tests__/fixtures.js'; +import { operation, param } from '../../__tests__/fixtures.js'; import { operationSignature, templatePathParams } from '../typescript.js'; describe('operationSignature', () => { diff --git a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts index 3253498dbd..aa15050a15 100644 --- a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts +++ b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts @@ -3,7 +3,7 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; // The prepare-time transform that rewrites the repo-facing intro and modify loop -// into their user-repo equivalents (plain .mjs, importable straight from scripts/). +// into their user-repo equivalents (importable straight from scripts/). import { ejectedSkill } from '../../../scripts/ejected-skill.mjs'; // Skill-first development: EVERY generator lives in a folder with its own AGENTS.md — @@ -11,11 +11,19 @@ import { ejectedSkill } from '../../../scripts/ejected-skill.mjs'; // missing its modify-loop anchors, fails here. const generatorsDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -/** Language generators: self-contained folders, ejected as their own source. */ -const LANGUAGE = ['python', 'go', 'php']; -/** TypeScript generators: thin entries over shared emitters, ejected bundled with them. */ -const TYPESCRIPT = ['typescript', 'zod', 'mock', 'cli', 'swr', 'tanstack-query', 'transformers']; -const EJECTABLE = [...LANGUAGE, ...TYPESCRIPT]; +/** Every generator: a self-contained folder, ejected as its own source. */ +const EJECTABLE = [ + 'python', + 'go', + 'php', + 'typescript', + 'zod', + 'mock', + 'cli', + 'swr', + 'tanstack-query', + 'transformers', +]; describe.each(EJECTABLE)('%s generator skill', (name) => { const skillPath = join(generatorsDir, name, 'AGENTS.md'); @@ -32,13 +40,7 @@ describe.each(EJECTABLE)('%s generator skill', (name) => { }); }); -describe.each(LANGUAGE)('%s generator skill ships to users', (name) => { - const skillPath = join(generatorsDir, name, 'AGENTS.md'); - - it('names its runtime', () => { - expect(readFileSync(skillPath, 'utf-8')).toContain('runtime/'); - }); - +describe.each(EJECTABLE)('%s generator skill ships to users', (name) => { it('ships without repo-only references — the user has no prepare script or vitest', () => { const asset = join(generatorsDir, '../../eject-assets/skills', `${name}-generator`, 'SKILL.md'); const shipped = readFileSync(asset, 'utf-8'); @@ -48,23 +50,10 @@ describe.each(LANGUAGE)('%s generator skill ships to users', (name) => { }); }); -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'); - // A language generator ships as its source folder (entry index.ts); a TypeScript - // generator ships as one bundled .mjs. - const assetEntry = LANGUAGE.includes(name) - ? join(assetsDir, 'generators', name, 'index.ts') - : join(assetsDir, 'generators', `${name}.mjs`); + // Every generator ships as its source folder, entry index.ts. + const assetEntry = join(assetsDir, 'generators', name, 'index.ts'); it('has a generator asset and a skill beside it', () => { expect(existsSync(assetEntry)).toBe(true); @@ -80,11 +69,10 @@ describe.each(EJECTABLE)('%s ships an eject asset', (name) => { 'utf-8' ); const source = readFileSync(join(generatorsDir, name, 'AGENTS.md'), 'utf-8'); - expect(shipped).toBe(ejectedSkill(source, name, { folder: LANGUAGE.includes(name) })); + 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(assetEntry, 'utf-8'); expect(asset).toMatch(new RegExp(`name: ['"]${name}['"]`)); expect(asset).toMatch(/requiresGenerator: ['"]\^\d+\.\d+\.\d+['"]/); diff --git a/packages/client-generator/src/generators/__tests__/go-runtime-embed.test.ts b/packages/client-generator/src/generators/__tests__/go-runtime-embed.test.ts index 9b59ec6acc..40ca26ca18 100644 --- a/packages/client-generator/src/generators/__tests__/go-runtime-embed.test.ts +++ b/packages/client-generator/src/generators/__tests__/go-runtime-embed.test.ts @@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { GO_RUNTIME_SOURCE } from '../../emitters/go-runtime-sources.js'; +import { GO_RUNTIME_SOURCE } from '../../runtime-sources/go.js'; const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const hasGo = spawnSync('go', ['version']).status === 0; diff --git a/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts b/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts index 3dc1b84243..986bf7dc43 100644 --- a/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts +++ b/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts @@ -1,36 +1,66 @@ -import { readdirSync, readFileSync } from 'node:fs'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -// The python generator is the flywheel's proof: it must be authored EXACTLY the -// way the AGENTS.md skill teaches users' agents — with the language-neutral -// toolkit only, through the SAME package specifiers an ejected copy carries (a -// tsconfig `paths` entry resolves them to src). 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. +// Every built-in generator must be authored EXACTLY the way the AGENTS.md skill +// teaches users' agents — through the public package specifiers an ejected copy +// carries (a tsconfig `paths` entry resolves them to src). Any import outside a +// folder's allowlist is a dogfooding violation; for the language generators it also +// breaks the promise that a python-only selection never loads `typescript`. +// +// The sharing tiers (ADR-0020): the neutral toolkit, the folder's OWN printer — +// never another language's — the runtime sources, and a required generator's +// published contract. Node builtins and `@redocly/openapi-core` (the toolkit's own +// dependency) are platform, not sharing. const SHARED_SPECIFIERS = [ - '@redocly/client-generator', // the neutral toolkit + the IR types + the generator contract - '@redocly/client-generator/runtime-sources', // pure embedded strings, generated at prepare time + '@redocly/client-generator', + '@redocly/client-generator/runtime-sources', + '@redocly/openapi-core', ]; -describe.each(['python', 'go', 'php'])('%s folder dogfooding invariant', (language) => { +const GENERATORS: Array<{ name: string; printer?: string; contracts?: string[] }> = [ + { name: 'python', printer: 'python' }, + { name: 'go', printer: 'go' }, + { name: 'php', printer: 'php' }, + { name: 'typescript', printer: 'typescript' }, + { name: 'zod', printer: 'typescript' }, + { name: 'mock', printer: 'typescript' }, + { name: 'transformers', printer: 'typescript' }, + // The wrappers and the cli code against the typescript SDK's published ABI — + // the `requires: ['typescript']` edge in the registry. + { name: 'swr', printer: 'typescript', contracts: ['typescript'] }, + { name: 'tanstack-query', printer: 'typescript', contracts: ['typescript'] }, + { name: 'cli', printer: 'typescript', contracts: ['typescript'] }, +]; + +describe.each(GENERATORS)('$name folder dogfooding invariant', ({ name, printer, contracts }) => { it('imports only what the authoring skill offers to any custom generator', () => { - const folder = resolve(dirname(fileURLToPath(import.meta.url)), '..', language); - const stageFiles = readdirSync(folder).filter((name) => name.endsWith('.ts')); + const folder = resolve(dirname(fileURLToPath(import.meta.url)), '..', name); + // Top-level stage files only: a `runtime/` subfolder holds the embedded runtime's + // own sources, which keep their intra-runtime relative imports by design. + const stageFiles = readdirSync(folder).filter( + (entry) => entry.endsWith('.ts') && statSync(resolve(folder, entry)).isFile() + ); expect(stageFiles.length).toBeGreaterThan(0); - // A generator's sharing tiers (ADR-0020): the neutral toolkit, its OWN language - // printer — never another language's — the runtime sources, and its own stage files. const allowed = new Set([ ...SHARED_SPECIFIERS, - `@redocly/client-generator/printers/${language}`, + `@redocly/client-generator/printers/${printer}`, + ...(contracts ?? []).map((required) => `@redocly/client-generator/contracts/${required}`), ]); - for (const name of stageFiles) { - const source = readFileSync(resolve(folder, name), 'utf-8'); - const specifiers = [...source.matchAll(/from '([^']+)'/g)].map((match) => match[1]); + for (const file of stageFiles) { + const source = readFileSync(resolve(folder, file), 'utf-8'); + // Real module imports only — generators also EMIT import lines inside template + // literals (`'msw'`, `'./runtime/factory.${ext}'`), which are output, not imports. + const specifiers = [...source.matchAll(/^(?:import|export|\}).* from '([^']+)';$/gm)].map( + (match) => match[1] + ); const violations = specifiers.filter( - (specifier) => !allowed.has(specifier) && !/^\.\/[a-z-]+\.ts$/.test(specifier) + (specifier) => + !allowed.has(specifier) && + !/^\.\/[a-z-]+\.ts$/.test(specifier) && + !specifier.startsWith('node:') ); - expect(violations, name).toEqual([]); + expect(violations, `${name}/${file}`).toEqual([]); } }); }); diff --git a/packages/client-generator/src/generators/__tests__/mock.test.ts b/packages/client-generator/src/generators/__tests__/mock.test.ts index 36ae532485..bdc4778cdd 100644 --- a/packages/client-generator/src/generators/__tests__/mock.test.ts +++ b/packages/client-generator/src/generators/__tests__/mock.test.ts @@ -1,4 +1,4 @@ -import { apiModel, namedSchema, operation, response } from '../../emitters/__tests__/fixtures.js'; +import { apiModel, namedSchema, operation, response } from '../../__tests__/fixtures.js'; import { mockGenerator as mockGeneratorEntry } from '../mock/index.js'; import { generatorInput } from './fixtures/generator-input.js'; diff --git a/packages/client-generator/src/generators/__tests__/php-runtime-embed.test.ts b/packages/client-generator/src/generators/__tests__/php-runtime-embed.test.ts index b3f655d03d..2e2ccc59f3 100644 --- a/packages/client-generator/src/generators/__tests__/php-runtime-embed.test.ts +++ b/packages/client-generator/src/generators/__tests__/php-runtime-embed.test.ts @@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { PHP_RUNTIME_SOURCE } from '../../emitters/php-runtime-sources.js'; +import { PHP_RUNTIME_SOURCE } from '../../runtime-sources/php.js'; const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const hasPhp = spawnSync('php', ['--version']).status === 0; diff --git a/packages/client-generator/src/generators/__tests__/python-runtime-embed.test.ts b/packages/client-generator/src/generators/__tests__/python-runtime-embed.test.ts index 6dea8d127a..13c8b458de 100644 --- a/packages/client-generator/src/generators/__tests__/python-runtime-embed.test.ts +++ b/packages/client-generator/src/generators/__tests__/python-runtime-embed.test.ts @@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { PYTHON_RUNTIME_SOURCES } from '../../emitters/python-runtime-sources.js'; +import { PYTHON_RUNTIME_SOURCES } from '../../runtime-sources/python.js'; const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const hasPython = spawnSync('python3', ['--version']).status === 0; diff --git a/packages/client-generator/src/generators/__tests__/runtime-embed-freshness.test.ts b/packages/client-generator/src/generators/__tests__/runtime-embed-freshness.test.ts index 4a29ba4e65..7354cb3426 100644 --- a/packages/client-generator/src/generators/__tests__/runtime-embed-freshness.test.ts +++ b/packages/client-generator/src/generators/__tests__/runtime-embed-freshness.test.ts @@ -8,9 +8,9 @@ import { readFileSync, readdirSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { GO_RUNTIME_SOURCE } from '../../emitters/go-runtime-sources.js'; -import { PHP_RUNTIME_SOURCE } from '../../emitters/php-runtime-sources.js'; -import { PYTHON_RUNTIME_SOURCES } from '../../emitters/python-runtime-sources.js'; +import { GO_RUNTIME_SOURCE } from '../../runtime-sources/go.js'; +import { PHP_RUNTIME_SOURCE } from '../../runtime-sources/php.js'; +import { PYTHON_RUNTIME_SOURCES } from '../../runtime-sources/python.js'; const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const STALE = 'stale embed — run `npm run prepare -w @redocly/client-generator`'; diff --git a/packages/client-generator/src/generators/__tests__/swr.test.ts b/packages/client-generator/src/generators/__tests__/swr.test.ts index b68eabc6b7..08ba50429c 100644 --- a/packages/client-generator/src/generators/__tests__/swr.test.ts +++ b/packages/client-generator/src/generators/__tests__/swr.test.ts @@ -1,4 +1,4 @@ -import { apiModel, operation } from '../../emitters/__tests__/fixtures.js'; +import { apiModel, operation } from '../../__tests__/fixtures.js'; import { builtinGenerators } from '../index.js'; import { swrGenerator as swrGeneratorEntry } from '../swr/index.js'; import { generatorInput } from './fixtures/generator-input.js'; 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 cd19ab62d7..b633efaadd 100644 --- a/packages/client-generator/src/generators/__tests__/tanstack-query.test.ts +++ b/packages/client-generator/src/generators/__tests__/tanstack-query.test.ts @@ -1,4 +1,4 @@ -import { apiModel, operation } from '../../emitters/__tests__/fixtures.js'; +import { apiModel, operation } from '../../__tests__/fixtures.js'; import { builtinGenerators } from '../index.js'; import { tanstackQueryGenerator as tanstackQueryGeneratorEntry } from '../tanstack-query/index.js'; import { generatorInput } from './fixtures/generator-input.js'; diff --git a/packages/client-generator/src/generators/__tests__/transformers.test.ts b/packages/client-generator/src/generators/__tests__/transformers.test.ts index dae5517c8d..d697d41ed8 100644 --- a/packages/client-generator/src/generators/__tests__/transformers.test.ts +++ b/packages/client-generator/src/generators/__tests__/transformers.test.ts @@ -1,4 +1,4 @@ -import { apiModel, namedSchema } from '../../emitters/__tests__/fixtures.js'; +import { apiModel, namedSchema } from '../../__tests__/fixtures.js'; import { builtinGenerators } from '../index.js'; import { transformersGenerator as transformersGeneratorEntry } from '../transformers/index.js'; import { generatorInput } from './fixtures/generator-input.js'; diff --git a/packages/client-generator/src/generators/__tests__/zod.test.ts b/packages/client-generator/src/generators/__tests__/zod.test.ts index d3c4ca5792..9c41775493 100644 --- a/packages/client-generator/src/generators/__tests__/zod.test.ts +++ b/packages/client-generator/src/generators/__tests__/zod.test.ts @@ -1,4 +1,4 @@ -import { apiModel, namedSchema } from '../../emitters/__tests__/fixtures.js'; +import { apiModel, namedSchema } from '../../__tests__/fixtures.js'; import { zodGenerator as zodGeneratorEntry } from '../zod/index.js'; import { generatorInput } from './fixtures/generator-input.js'; diff --git a/packages/client-generator/src/generators/cli/docs.ts b/packages/client-generator/src/generators/cli/docs.ts index c77f8a007c..4dcfaba7ea 100644 --- a/packages/client-generator/src/generators/cli/docs.ts +++ b/packages/client-generator/src/generators/cli/docs.ts @@ -3,8 +3,13 @@ // runtime addresses groups and reads credentials with. A second model would drift from // the tool the first time either side changed. -import { Printer } from '../../authoring/printer.js'; -import { constantCase, groupSlug, type CliCommand, type CliFlag } from './runtime/cli.js'; +import { + type CliCommand, + type CliFlag, + constantCase, + groupSlug, + Printer, +} from '@redocly/client-generator'; export type CliDocsOptions = { /** Page heading. */ diff --git a/packages/client-generator/src/generators/cli/engine-source.ts b/packages/client-generator/src/generators/cli/engine-source.ts new file mode 100644 index 0000000000..982d5035fd --- /dev/null +++ b/packages/client-generator/src/generators/cli/engine-source.ts @@ -0,0 +1,17 @@ +// The cli engine's embeddable source, snapshotted at prepare time (see +// scripts/generate-runtime-sources.mjs). + +import { + RUNTIME_SOURCES, + RUNTIME_SOURCES_STRIPPED, +} from '@redocly/client-generator/runtime-sources'; + +/** The cli engine (`runCli` + types) stripped for embedding into `.cli.ts`. */ +export function embedCliRuntime(): string { + return RUNTIME_SOURCES_STRIPPED['cli.ts']; +} + +/** The cli engine RAW, for `runtime: 'module'` (written as `runtime/cli.ts`). */ +export function cliRuntimeSource(): string { + return RUNTIME_SOURCES['cli.ts']; +} diff --git a/packages/client-generator/src/generators/cli/index.ts b/packages/client-generator/src/generators/cli/index.ts index 0c9c9b4bf7..2049c06da9 100644 --- a/packages/client-generator/src/generators/cli/index.ts +++ b/packages/client-generator/src/generators/cli/index.ts @@ -1,11 +1,15 @@ +import { + type CodeSample, + type Generator, + groupSlug, + type OperationModel, + type SampleContext, +} from '@redocly/client-generator'; import { join } from 'node:path'; -import { cliRuntimeSource } from '../../emitters/inline-runtime.js'; -import type { OperationModel } from '../../intermediate-representation/model.js'; -import type { CodeSample, Generator, SampleContext } from '../types.js'; -import { renderCliDocs } from './docs.js'; -import { cliAuthSchemes, commandData, renderCliModule } from './render.js'; -import { groupSlug } from './runtime/cli.js'; +import { renderCliDocs } from './docs.ts'; +import { cliRuntimeSource } from './engine-source.ts'; +import { cliAuthSchemes, commandData, renderCliModule } from './render.ts'; /** * The cli generator: a bin-ready `.cli.ts` — a zero-dependency, typed diff --git a/packages/client-generator/src/generators/cli/render.ts b/packages/client-generator/src/generators/cli/render.ts index 8fd9edacba..b755238ebc 100644 --- a/packages/client-generator/src/generators/cli/render.ts +++ b/packages/client-generator/src/generators/cli/render.ts @@ -2,25 +2,23 @@ // `.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 { flatInputShape } from '../../contracts/typescript.js'; -import { embedCliRuntime } from '../../emitters/inline-runtime.js'; -import type { - ApiModel, - OperationModel, - ParamModel, - SchemaModel, -} from '../../intermediate-representation/model.js'; -import type { ModelPagination } from '../../pagination.js'; import { - constantCase, - groupSlug, + type ApiModel, + casing, type CliAuthScheme, type CliCommand, type CliFlag, -} from './runtime/cli.js'; + constantCase, + groupSlug, + type ModelPagination, + type OperationModel, + type ParamModel, + type SchemaModel, +} from '@redocly/client-generator'; +import { flatInputShape } from '@redocly/client-generator/contracts/typescript'; +import { logger } from '@redocly/openapi-core'; + +import { embedCliRuntime } from './engine-source.ts'; // The generated-by banner every emitted module carries (same lines as the pipeline's // `input.banner`, rendered in `//` syntax). diff --git a/packages/client-generator/src/generators/cli/runtime/cli.ts b/packages/client-generator/src/generators/cli/runtime/cli.ts index 575d842a22..0c12f2dc62 100644 --- a/packages/client-generator/src/generators/cli/runtime/cli.ts +++ b/packages/client-generator/src/generators/cli/runtime/cli.ts @@ -5,89 +5,28 @@ // the module itself stays dependency-free and fully unit-testable; the emitted // entry fills the defaults with real `node:fs`/`process` bindings. -/** One flag derived from a query parameter. */ -export type CliFlag = { - /** Kebab-cased flag name (`--page-size`). */ - name: string; - /** Original wire parameter name. */ - param: string; - type: 'string' | 'number' | 'boolean' | 'array'; - required: boolean; - enum?: string[]; - description?: string; -}; +import { + constantCase, + groupSlug, + type CliAuthScheme, + type CliCommand, + type CliFlag, + type CliGlobals, + type CliWiring, + type CommandContext, + type CommandSource, + type CustomCommand, +} from '../../../cli-contract.js'; -/** One executable command, derived from the IR at generate time. Pure data. */ -export type CliCommand = { - /** Tag; absent = flat/untagged. */ - group?: string; - name: string; - summary?: string; - method: string; - path: string; - /** Path params, in path-template order. Always required — that is what a path is. */ - positionals: Array<{ - name: string; - type?: CliFlag['type']; - description?: string; - }>; - flags: CliFlag[]; - /** - * Present when the operation takes a JSON request body. `merged` marks a body whose own - * properties a flat-style call spells at the top level (the generator decides this from - * the schema, so the CLI and the client can never disagree). - */ - body?: { required: boolean; merged?: boolean }; - /** - * The content type of a request body that is NOT JSON (multipart, url-encoded, binary). - * `--json` cannot build one, so the command is reported as library-only rather than - * offered as if it were runnable. - */ - unsupportedBody?: string; - paginated?: boolean; - /** `'grouped'` marks a command whose client method takes namespaced inputs even on a - * flat-style client, because its merged names would collide. */ - argsStyle?: 'grouped'; - sse?: boolean; - blob?: boolean; - /** IR schemas for the `schema` command, serialized verbatim. */ - schemas?: { request?: unknown; response?: unknown }; -}; +export type { CliFlag } from '../../../cli-contract.js'; -export type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' }; - -export type CliWiring = { - /** The name the CLI is invoked as, for help output only. The generated entry reads it - * from `process.argv[1]`, so help never names a command that is not installed. */ - name: string; - /** Credential variable prefix, constant-cased: `CAFE` gives `CAFE_TOKEN`. Fixed at - * generation from the output file name, so renaming the binary keeps the variables - * a published CLI already documents. A composed entry sets one per api alias. */ - envPrefix: string; - /** The generated instance client. */ - client: Record; - /** How that client takes its inputs. Defaults to `'grouped'`, the generated default. */ - argsStyle?: 'grouped' | 'flat'; - configure: (config: Record) => void; - /** Security schemes of the API — drives env-var credential resolution. */ - schemes?: CliAuthScheme[]; - env?: Record; - stdin?: () => string; - readFile?: (path: string) => string; - writeFile?: (path: string, data: Uint8Array) => void; - stdout: (line: string) => void; - stderr: (line: string) => void; -}; +export type { CliCommand } from '../../../cli-contract.js'; -export type CliGlobals = { - serverUrl?: string; - format?: 'json' | 'ndjson'; - dryRun?: boolean; - pageAll?: boolean; - output?: string; - token?: string; - json?: string; -}; +export type { CliAuthScheme } from '../../../cli-contract.js'; + +export type { CliWiring } from '../../../cli-contract.js'; + +export type { CliGlobals } from '../../../cli-contract.js'; export type CliInvocation = | { kind: 'help'; topic?: CliCommand | string } @@ -101,37 +40,11 @@ export type CliInvocation = } | { kind: 'usage-error'; message: string }; -/** - * A hand-written command composed NEXT TO the generated ones: the same data shape plus a - * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is - * how behavior that is not in the description (a `login`, a doctor command) joins the - * binary without the generator ever learning what it does. - */ -export type CustomCommand = { - name: string; - group?: string; - summary?: string; - positionals?: CliCommand['positionals']; - flags?: CliFlag[]; - /** Returns the process exit code; throwing exits 1 with the standard error JSON. */ - handler: (context: CommandContext) => number | Promise; -}; +export type { CustomCommand } from '../../../cli-contract.js'; -export type CommandContext = { - positionals: Record; - params: Record; - globals: CliGlobals; - wiring: CliWiring; -}; +export type { CommandContext } from '../../../cli-contract.js'; -/** One API's contribution to a composed binary: its commands behind a namespace, with its - * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */ -export type CommandSource = { - namespace?: string; - commands: Array; - /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */ - wiring?: CliWiring; -}; +export type { CommandSource } from '../../../cli-contract.js'; type ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] }; @@ -187,18 +100,7 @@ export function invokedName(scriptPath: string | undefined, fallback: string): s return name === '' ? fallback : name; } -/** - * The shell-typable form of a group name: an OpenAPI tag can contain spaces ("Some - * multi-word tag"), which only resolves if the user quotes it. Commands are addressed by - * this slug; help still shows the original tag. - */ -export function groupSlug(group: string): string { - return group - .toLowerCase() - .split(/[^a-z0-9]+/) - .filter(Boolean) - .join('-'); -} +export { groupSlug } from '../../../cli-contract.js'; /** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */ function oneLine(text: string): string { @@ -370,13 +272,7 @@ export function parseInvocation(commands: CliCommand[], argv: string[]): CliInvo return { kind: 'run', command, positionals, params, globals }; } -/** `cafe-api` → `CAFE_API`: the casing of every credential variable this CLI reads. */ -export function constantCase(value: string): string { - return value - .replace(/[^A-Za-z0-9]+/g, '_') - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') - .toUpperCase(); -} +export { constantCase } from '../../../cli-contract.js'; function resolveAuth(wiring: CliWiring, token: string | undefined): Record { const env = wiring.env ?? {}; diff --git a/packages/client-generator/src/generators/mock/__tests__/render.test.ts b/packages/client-generator/src/generators/mock/__tests__/render.test.ts index 7e08cc19e9..5f5a09f345 100644 --- a/packages/client-generator/src/generators/mock/__tests__/render.test.ts +++ b/packages/client-generator/src/generators/mock/__tests__/render.test.ts @@ -1,4 +1,4 @@ -import { apiModel, namedSchema, operation, param } from '../../../emitters/__tests__/fixtures.js'; +import { apiModel, namedSchema, operation, param } from '../../../__tests__/fixtures.js'; import { renderMockModule } from '../render.js'; describe('renderMockModule', () => { diff --git a/packages/client-generator/src/generators/mock/faker.ts b/packages/client-generator/src/generators/mock/faker.ts index 5b2e57e40e..6c9e604325 100644 --- a/packages/client-generator/src/generators/mock/faker.ts +++ b/packages/client-generator/src/generators/mock/faker.ts @@ -1,6 +1,6 @@ // Builds the body value for a faker-mode mock factory: a tree of // `@faker-js/faker` call expressions that produce realistic — and, with a seed, -// reproducible — data. Structurally mirrors `emitters/sample.ts`'s `walk` (same +// reproducible — data. Structurally mirrors `./sample.ts`'s `walk` (same // recursion + same visited-set cycle guard), but yields faker calls instead of a // static value. Nested refs are INLINED under the same cycle guard (never // `create()` calls), so a cyclic schema terminates with `null` at the cycle @@ -9,16 +9,17 @@ // `mockData` without touching call sites; `@faker-js/faker` becomes their // dev-dep while the real client stays dependency-free. -import type { DateType } from '../../authoring/options.js'; -import type { - NamedSchemaModel, - ScalarKind, - SchemaMetadata, - SchemaModel, -} from '../../intermediate-representation/model.js'; -import { codeLiteral } from '../../printers/typescript.js'; -import { splitIntersection } from './sample.js'; -import { expr, isObjectValue, type MockEntry, type MockValue, objectValue } from './values.js'; +import { + type DateType, + type NamedSchemaModel, + type ScalarKind, + type SchemaMetadata, + type SchemaModel, +} from '@redocly/client-generator'; +import { codeLiteral } from '@redocly/client-generator/printers/typescript'; + +import { splitIntersection } from './sample.ts'; +import { expr, isObjectValue, type MockEntry, type MockValue, objectValue } from './values.ts'; /** The faker-call value for an IR schema. Refs resolve against `schemas`; * recursion is cut with a visited-set (`null` at the cycle). `dateType` mirrors @@ -39,7 +40,7 @@ export function fakerExpression( /** * Sentinel returned by `walk` when a `$ref` re-enters a name already on the stack. * Containers turn it into the type-correct empty value for their position — an array - * to `[]`, a record to `{}`, an optional property to omission — mirroring `emitters/sample.ts` + * to `[]`, a record to `{}`, an optional property to omission — mirroring `./sample.ts` * so a recursive schema yields a faker tree that still satisfies its non-nullable type. * Only a required, non-container self-reference (an uninhabitable schema) degrades to null. */ diff --git a/packages/client-generator/src/generators/mock/index.ts b/packages/client-generator/src/generators/mock/index.ts index e9cad98fbf..c124c85bd1 100644 --- a/packages/client-generator/src/generators/mock/index.ts +++ b/packages/client-generator/src/generators/mock/index.ts @@ -1,7 +1,7 @@ +import type { Generator } from '@redocly/client-generator'; import { join } from 'node:path'; -import type { Generator } from '../types.js'; -import { renderMockModule } from './render.js'; +import { renderMockModule } from './render.ts'; /** * The mock generator: a standalone `.mocks.ts` module of MSW handlers and diff --git a/packages/client-generator/src/generators/mock/render.ts b/packages/client-generator/src/generators/mock/render.ts index 0e0992c276..c6f50a247f 100644 --- a/packages/client-generator/src/generators/mock/render.ts +++ b/packages/client-generator/src/generators/mock/render.ts @@ -5,20 +5,24 @@ // literals — source-text templates — so the generated module depends only on // `msw`; the real client stays zero-dependency. -import { isPlainObject } from '@redocly/openapi-core'; - -import type { DateType } from '../../authoring/options.js'; import { allOperations, type ApiModel, + type DateType, type NamedSchemaModel, type OperationModel, type ResponseBodyModel, type SchemaModel, -} from '../../intermediate-representation/model.js'; -import { codeLiteral, isIdentifier, pascalCase } from '../../printers/typescript.js'; -import { fakerExpression } from './faker.js'; -import { sampleValue, SampleExpression } from './sample.js'; +} from '@redocly/client-generator'; +import { + codeLiteral, + isIdentifier, + pascalCase, +} from '@redocly/client-generator/printers/typescript'; +import { isPlainObject } from '@redocly/openapi-core'; + +import { fakerExpression } from './faker.ts'; +import { sampleValue, SampleExpression } from './sample.ts'; import { expr, isObjectValue, @@ -26,7 +30,7 @@ import { objectValue, renderMockValue, spreadInto, -} from './values.js'; +} from './values.ts'; const INDENT = ' '; diff --git a/packages/client-generator/src/generators/mock/sample.ts b/packages/client-generator/src/generators/mock/sample.ts index 9be73238b3..217bfcd89b 100644 --- a/packages/client-generator/src/generators/mock/sample.ts +++ b/packages/client-generator/src/generators/mock/sample.ts @@ -1,13 +1,12 @@ +import { + type DateType, + type NamedSchemaModel, + type ScalarKind, + type SchemaMetadata, + type SchemaModel, +} from '@redocly/client-generator'; import { isPlainObject } from '@redocly/openapi-core'; -import type { DateType } from '../../authoring/options.js'; -import type { - NamedSchemaModel, - ScalarKind, - SchemaMetadata, - SchemaModel, -} from '../../intermediate-representation/model.js'; - /** A sampled value the emitter must print as a raw TS expression rather than a JSON * literal — e.g. a `format: binary` field, whose generated type is `Blob`. The `code` * strings are generator-authored constants (never spec-derived), so emitting them diff --git a/packages/client-generator/src/generators/mock/values.ts b/packages/client-generator/src/generators/mock/values.ts index 047621ac4c..6c430ca8da 100644 --- a/packages/client-generator/src/generators/mock/values.ts +++ b/packages/client-generator/src/generators/mock/values.ts @@ -2,7 +2,7 @@ // (for intersection merging and `...overrides` spreading) until the final render, // where indentation is threaded. Deliberately tiny. -import { safeIdent, sanitizeCodeString } from '../../printers/typescript.js'; +import { safeIdent, sanitizeCodeString } from '@redocly/client-generator/printers/typescript'; export type MockEntry = { key: string; value: MockValue } | { spread: string }; diff --git a/packages/client-generator/src/generators/swr/__tests__/render.test.ts b/packages/client-generator/src/generators/swr/__tests__/render.test.ts index e9edc79cf1..a32b8e8163 100644 --- a/packages/client-generator/src/generators/swr/__tests__/render.test.ts +++ b/packages/client-generator/src/generators/swr/__tests__/render.test.ts @@ -1,10 +1,4 @@ -import { - apiModel, - namedSchema, - operation, - param, - SCALAR, -} from '../../../emitters/__tests__/fixtures.js'; +import { apiModel, namedSchema, operation, param, SCALAR } from '../../../__tests__/fixtures.js'; import { renderSwrModule } from '../render.js'; const SDK = './client.js'; diff --git a/packages/client-generator/src/generators/swr/index.ts b/packages/client-generator/src/generators/swr/index.ts index eb547c84f5..7627c68021 100644 --- a/packages/client-generator/src/generators/swr/index.ts +++ b/packages/client-generator/src/generators/swr/index.ts @@ -1,7 +1,7 @@ +import type { Generator } from '@redocly/client-generator'; import { join } from 'node:path'; -import type { Generator } from '../types.js'; -import { renderSwrModule } from './render.js'; +import { renderSwrModule } from './render.ts'; /** * The swr generator: a standalone `.swr.ts` module of SWR hooks wrapping the diff --git a/packages/client-generator/src/generators/swr/render.ts b/packages/client-generator/src/generators/swr/render.ts index 5d0b85e5ee..4d3b746d8c 100644 --- a/packages/client-generator/src/generators/swr/render.ts +++ b/packages/client-generator/src/generators/swr/render.ts @@ -8,6 +8,7 @@ // `swr`/`swr/mutation` are the consumer's peer; the sdk stays dependency-free. // Source-text templates throughout. +import type { ApiModel, OperationModel } from '@redocly/client-generator'; import { hasInputs, isQuery, @@ -15,9 +16,8 @@ import { sdkNamedImportText, variablesName, wrappableOperations, -} from '../../contracts/typescript.js'; -import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; -import { pascalCase } from '../../printers/typescript.js'; +} from '@redocly/client-generator/contracts/typescript'; +import { pascalCase } from '@redocly/client-generator/printers/typescript'; export type SwrOptions = { /** Import specifier for the sdk entry the operation functions/types live in. */ diff --git a/packages/client-generator/src/generators/tanstack-query/__tests__/render.test.ts b/packages/client-generator/src/generators/tanstack-query/__tests__/render.test.ts index c93a3c262b..ee51bbaae8 100644 --- a/packages/client-generator/src/generators/tanstack-query/__tests__/render.test.ts +++ b/packages/client-generator/src/generators/tanstack-query/__tests__/render.test.ts @@ -1,10 +1,4 @@ -import { - apiModel, - namedSchema, - operation, - param, - SCALAR, -} from '../../../emitters/__tests__/fixtures.js'; +import { apiModel, namedSchema, operation, param, SCALAR } from '../../../__tests__/fixtures.js'; import { resolveModelPagination, type PaginationConfig } from '../../../pagination.js'; import { renderTanstackModule } from '../render.js'; diff --git a/packages/client-generator/src/generators/tanstack-query/index.ts b/packages/client-generator/src/generators/tanstack-query/index.ts index 9d7b816d9b..245555623d 100644 --- a/packages/client-generator/src/generators/tanstack-query/index.ts +++ b/packages/client-generator/src/generators/tanstack-query/index.ts @@ -1,7 +1,7 @@ +import type { Generator } from '@redocly/client-generator'; import { join } from 'node:path'; -import type { Generator } from '../types.js'; -import { renderTanstackModule } from './render.js'; +import { renderTanstackModule } from './render.ts'; /** * The tanstack-query generator: a standalone `.tanstack.ts` module of diff --git a/packages/client-generator/src/generators/tanstack-query/render.ts b/packages/client-generator/src/generators/tanstack-query/render.ts index 45b21ff5f0..d9218dbfd9 100644 --- a/packages/client-generator/src/generators/tanstack-query/render.ts +++ b/packages/client-generator/src/generators/tanstack-query/render.ts @@ -14,19 +14,24 @@ // is generator-derived (sanitized operation names, JSON-pointer property chains built // here) — never raw spec text. +import { + type ApiModel, + type ModelPagination, + type OperationModel, + type PaginationSpec, + resolveSchemaPointer, +} from '@redocly/client-generator'; import { hasInputs, isQuery, variablesName, wrappableOperations, -} from '../../contracts/typescript.js'; -import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; +} from '@redocly/client-generator/contracts/typescript'; import { - type ModelPagination, - type PaginationSpec, - resolveSchemaPointer, -} from '../../pagination.js'; -import { codeString, isSafeIdentifier, safeIdent } from '../../printers/typescript.js'; + codeString, + isSafeIdentifier, + safeIdent, +} from '@redocly/client-generator/printers/typescript'; export type TanstackOptions = { /** Import specifier for the sdk entry the `client` instance and types live in. */ diff --git a/packages/client-generator/src/generators/transformers/index.ts b/packages/client-generator/src/generators/transformers/index.ts index 06755c8bca..81348cd405 100644 --- a/packages/client-generator/src/generators/transformers/index.ts +++ b/packages/client-generator/src/generators/transformers/index.ts @@ -1,7 +1,7 @@ +import type { Generator } from '@redocly/client-generator'; import { join } from 'node:path'; -import type { Generator } from '../types.js'; -import { renderTransformersModule } from './render.js'; +import { renderTransformersModule } from './render.ts'; /** * The transformers generator: a standalone `.transformers.ts` module of diff --git a/packages/client-generator/src/generators/transformers/render.ts b/packages/client-generator/src/generators/transformers/render.ts index 2899dae092..917e48419b 100644 --- a/packages/client-generator/src/generators/transformers/render.ts +++ b/packages/client-generator/src/generators/transformers/render.ts @@ -9,12 +9,8 @@ // `transformPet` calls `transformOwner(data["owner"])` when `Pet.owner` is an // `Owner` that has dates. Source-text templates throughout. -import type { - ApiModel, - NamedSchemaModel, - SchemaModel, -} from '../../intermediate-representation/model.js'; -import { pascalCase, safeIdent } from '../../printers/typescript.js'; +import type { ApiModel, NamedSchemaModel, SchemaModel } from '@redocly/client-generator'; +import { pascalCase, safeIdent } from '@redocly/client-generator/printers/typescript'; const INDENT = ' '; diff --git a/packages/client-generator/src/generators/typescript/__tests__/banner.test.ts b/packages/client-generator/src/generators/typescript/__tests__/banner.test.ts index 89b1fdca0b..edace8f6cd 100644 --- a/packages/client-generator/src/generators/typescript/__tests__/banner.test.ts +++ b/packages/client-generator/src/generators/typescript/__tests__/banner.test.ts @@ -1,4 +1,4 @@ -import { apiModel } from '../../../emitters/__tests__/fixtures.js'; +import { apiModel } from '../../../__tests__/fixtures.js'; import { banner, HEADER, renderTitleComment } from '../banner.js'; describe('banner', () => { diff --git a/packages/client-generator/src/generators/typescript/__tests__/client-assembly.test.ts b/packages/client-generator/src/generators/typescript/__tests__/client-assembly.test.ts index 16926bb018..55ef49777c 100644 --- a/packages/client-generator/src/generators/typescript/__tests__/client-assembly.test.ts +++ b/packages/client-generator/src/generators/typescript/__tests__/client-assembly.test.ts @@ -7,7 +7,7 @@ import { param, response, SCALAR, -} from '../../../emitters/__tests__/fixtures.js'; +} from '../../../__tests__/fixtures.js'; import type { ApiModel } from '../../../intermediate-representation/model.js'; import { resolveModelPagination } from '../../../pagination.js'; import type { EmitOptions } from '../../types.js'; diff --git a/packages/client-generator/src/generators/typescript/__tests__/descriptor.test.ts b/packages/client-generator/src/generators/typescript/__tests__/descriptor.test.ts index b641db8562..7b886d895a 100644 --- a/packages/client-generator/src/generators/typescript/__tests__/descriptor.test.ts +++ b/packages/client-generator/src/generators/typescript/__tests__/descriptor.test.ts @@ -1,4 +1,4 @@ -import { apiModel, modelWith, operation, param } from '../../../emitters/__tests__/fixtures.js'; +import { apiModel, modelWith, operation, param } from '../../../__tests__/fixtures.js'; import type { ApiModel, OperationModel, diff --git a/packages/client-generator/src/emitters/__tests__/inline-runtime.test.ts b/packages/client-generator/src/generators/typescript/__tests__/inline-runtime.test.ts similarity index 100% rename from packages/client-generator/src/emitters/__tests__/inline-runtime.test.ts rename to packages/client-generator/src/generators/typescript/__tests__/inline-runtime.test.ts diff --git a/packages/client-generator/src/generators/typescript/__tests__/operations.test.ts b/packages/client-generator/src/generators/typescript/__tests__/operations.test.ts index eddafd5ed3..d7ec563d55 100644 --- a/packages/client-generator/src/generators/typescript/__tests__/operations.test.ts +++ b/packages/client-generator/src/generators/typescript/__tests__/operations.test.ts @@ -5,7 +5,7 @@ import { namedSchema, operation, param, -} from '../../../emitters/__tests__/fixtures.js'; +} from '../../../__tests__/fixtures.js'; // One operation's developer-facing surface in the descriptor-wired single-file client: // the input shape in both styles, and the `*` aliases. The wiring itself (Ops, // OPERATIONS, client, sugar) is covered in client-assembly.test.ts. diff --git a/packages/client-generator/src/generators/typescript/__tests__/type-guards.test.ts b/packages/client-generator/src/generators/typescript/__tests__/type-guards.test.ts index 4ac4712430..bf4b198035 100644 --- a/packages/client-generator/src/generators/typescript/__tests__/type-guards.test.ts +++ b/packages/client-generator/src/generators/typescript/__tests__/type-guards.test.ts @@ -1,4 +1,4 @@ -import { apiModel, namedSchema } from '../../../emitters/__tests__/fixtures.js'; +import { apiModel, namedSchema } from '../../../__tests__/fixtures.js'; import type { NamedSchemaModel, SchemaModel } from '../../../intermediate-representation/model.js'; import { emitClientSingleFile } from '../client-assembly.js'; diff --git a/packages/client-generator/src/generators/typescript/banner.ts b/packages/client-generator/src/generators/typescript/banner.ts index 345eecd584..c5ab93f8cd 100644 --- a/packages/client-generator/src/generators/typescript/banner.ts +++ b/packages/client-generator/src/generators/typescript/banner.ts @@ -1,5 +1,5 @@ -import type { ApiModel } from '../../intermediate-representation/model.js'; -import { escapeJsDoc, splitLines } from '../../printers/typescript.js'; +import type { ApiModel } from '@redocly/client-generator'; +import { escapeJsDoc, splitLines } from '@redocly/client-generator/printers/typescript'; /** The generated-by banner prepended to every emitted module. */ export const HEADER = `// Generated by @redocly/client-generator — do not edit by hand. diff --git a/packages/client-generator/src/generators/typescript/client-assembly.ts b/packages/client-generator/src/generators/typescript/client-assembly.ts index 42c2ab53aa..e0f2a5e9fd 100644 --- a/packages/client-generator/src/generators/typescript/client-assembly.ts +++ b/packages/client-generator/src/generators/typescript/client-assembly.ts @@ -1,34 +1,35 @@ // Client assembly, shared by both output modes. The generated file embeds the -// assembled runtime sources (emitters/inline-runtime.ts). Single-file layout: +// assembled runtime sources (./inline-runtime.ts). Single-file layout: // schema types → type guards → `*` aliases → Ops → OPERATIONS → embedded // runtime → (baked setup) → client instance → sugar — the embedded types are // already exported in place, so no re-exports. Split mode moves the schema types + // guards into a sibling `.schemas.ts` the entry re-exports (`emitClientSplit`). // Text templates throughout — no `typescript` at generate time. -import { - assembleInlineRuntime, - type InlineRuntimeNeeds, - runtimeModuleFiles, -} from '../../emitters/inline-runtime.js'; import { allOperations, type ApiModel, + type EmitOptions, type OperationModel, -} from '../../intermediate-representation/model.js'; -import { codeString } from '../../printers/typescript.js'; -import type { EmitOptions } from '../types.js'; -import { banner, HEADER, renderTitleComment } from './banner.js'; -import { packageIdents, renderDescriptors } from './descriptor.js'; -import { isTypedMultipart } from './operation-types.js'; +} from '@redocly/client-generator'; +import { codeString } from '@redocly/client-generator/printers/typescript'; + +import { banner, HEADER, renderTitleComment } from './banner.ts'; +import { packageIdents, renderDescriptors } from './descriptor.ts'; +import { + assembleInlineRuntime, + type InlineRuntimeNeeds, + runtimeModuleFiles, +} from './inline-runtime.ts'; +import { isTypedMultipart } from './operation-types.ts'; import { collectEntrySchemaRefs, type EmitContext, renderAliases, renderOpsType, -} from './render-client.js'; -import { renderTypeAliases } from './ts-type.js'; -import { renderTypeGuards } from './type-guards.js'; +} from './render-client.ts'; +import { renderTypeAliases } from './ts-type.ts'; +import { renderTypeGuards } from './type-guards.ts'; export function emitClientSingleFile(model: ApiModel, options: EmitOptions = {}): string { return emitClient(model, options).entry; diff --git a/packages/client-generator/src/generators/typescript/descriptor.ts b/packages/client-generator/src/generators/typescript/descriptor.ts index 2dcf13b084..681f3d81cf 100644 --- a/packages/client-generator/src/generators/typescript/descriptor.ts +++ b/packages/client-generator/src/generators/typescript/descriptor.ts @@ -3,23 +3,24 @@ // descriptor map (`satisfies Record` — the semver skew // guard against the runtime contract in src/runtime/types.ts). Text templates. -import { securityRequirements } from '../../authoring/operation.js'; -import type { DateType } from '../../authoring/options.js'; -import { WIRING_NAMES } from '../../emitters/reserved-names.js'; import { allOperations, type ApiModel, + type ArgsStyle, + type DateType, + type ModelPagination, type NamedSchemaModel, type OperationModel, + securityRequirements, type SecuritySchemeModel, -} from '../../intermediate-representation/model.js'; -import type { ModelPagination } from '../../pagination.js'; -import { codeLiteral, uniqueIdent } from '../../printers/typescript.js'; -import type { ArgsStyle } from '../types.js'; -import { isTypedMultipart } from './operation-types.js'; -import { flatInputShape, responseText } from './render-client.js'; -import { responseHeaderSpecs } from './response-headers.js'; -import { tsJsdoc } from './ts-type.js'; + WIRING_NAMES, +} from '@redocly/client-generator'; +import { codeLiteral, uniqueIdent } from '@redocly/client-generator/printers/typescript'; + +import { isTypedMultipart } from './operation-types.ts'; +import { flatInputShape, responseText } from './render-client.ts'; +import { responseHeaderSpecs } from './response-headers.ts'; +import { tsJsdoc } from './ts-type.ts'; /** * Operation-name → emitted-identifier plan. The full reserved set (wiring + imported diff --git a/packages/client-generator/src/generators/typescript/index.ts b/packages/client-generator/src/generators/typescript/index.ts index dbe80773d8..2c19237c30 100644 --- a/packages/client-generator/src/generators/typescript/index.ts +++ b/packages/client-generator/src/generators/typescript/index.ts @@ -1,10 +1,14 @@ +import { + type CodeSample, + type Generator, + type OperationModel, + renderReferencePage, + type SampleContext, +} from '@redocly/client-generator'; import { join } from 'node:path'; -import { renderReferencePage } from '../../authoring/reference-page.js'; -import type { OperationModel } from '../../intermediate-representation/model.js'; -import type { CodeSample, Generator, SampleContext } from '../types.js'; -import { emitClientSingleFile, emitClientSplit, emitRuntimeFiles } from './client-assembly.js'; -import { packageIdents } from './descriptor.js'; +import { emitClientSingleFile, emitClientSplit, emitRuntimeFiles } from './client-assembly.ts'; +import { packageIdents } from './descriptor.ts'; /** * The default generator: the full typed client (model types + runtime + endpoints). diff --git a/packages/client-generator/src/emitters/inline-runtime.ts b/packages/client-generator/src/generators/typescript/inline-runtime.ts similarity index 93% rename from packages/client-generator/src/emitters/inline-runtime.ts rename to packages/client-generator/src/generators/typescript/inline-runtime.ts index f1981828f3..be85a3aa84 100644 --- a/packages/client-generator/src/emitters/inline-runtime.ts +++ b/packages/client-generator/src/generators/typescript/inline-runtime.ts @@ -9,7 +9,7 @@ import { RUNTIME_SOURCES, RUNTIME_SOURCES_STRIPPED, type RuntimeModuleName, -} from './runtime-sources.js'; +} from '@redocly/client-generator/runtime-sources'; /** Which optional runtime capabilities the generated client must embed. */ export type InlineRuntimeNeeds = { @@ -99,16 +99,6 @@ function moduleFactory(needs: InlineRuntimeNeeds): string { return [imports.join('\n'), clientFactory(needs), reexports.join('\n')].join('\n\n'); } -/** The cli engine (`runCli` + types) stripped for embedding into `.cli.ts`. */ -export function embedCliRuntime(): string { - return RUNTIME_SOURCES_STRIPPED['cli.ts']; -} - -/** The cli engine RAW, for `runtime: 'module'` (written as `runtime/cli.ts`). */ -export function cliRuntimeSource(): string { - return RUNTIME_SOURCES['cli.ts']; -} - // The embedded equivalent of the package barrel's `createClient`: `createClientCore` // with only the included capabilities wired. EXPORTED — the design spec promises the // generated module re-exports `createClient`/`OPERATIONS`/`Ops` so apps can build diff --git a/packages/client-generator/src/generators/typescript/operation-signature.ts b/packages/client-generator/src/generators/typescript/operation-signature.ts index 92e609e067..25c2c46f9e 100644 --- a/packages/client-generator/src/generators/typescript/operation-signature.ts +++ b/packages/client-generator/src/generators/typescript/operation-signature.ts @@ -2,8 +2,8 @@ // operation's input type) and the wrapper generators (which forward it) read slot presence // and `Variables` naming from this one source, so a call and its type cannot drift. -import type { OperationModel, ParamModel } from '../../intermediate-representation/model.js'; -import { pascalCase } from '../../printers/typescript.js'; +import type { OperationModel, ParamModel } from '@redocly/client-generator'; +import { pascalCase } from '@redocly/client-generator/printers/typescript'; export type OperationSignature = { /** Slot presence — which input layers the operation has. */ diff --git a/packages/client-generator/src/generators/typescript/operation-types.ts b/packages/client-generator/src/generators/typescript/operation-types.ts index c61a015f77..5cbc7a0ec1 100644 --- a/packages/client-generator/src/generators/typescript/operation-types.ts +++ b/packages/client-generator/src/generators/typescript/operation-types.ts @@ -1,6 +1,6 @@ // Shared operation-shape predicates. -import type { RequestBodyModel } from '../../intermediate-representation/model.js'; +import type { RequestBodyModel } from '@redocly/client-generator'; /** * A multipart body whose schema is a concrete object — the case worth typing. Such a body diff --git a/packages/client-generator/src/generators/typescript/render-client.ts b/packages/client-generator/src/generators/typescript/render-client.ts index 0ccea3ebb6..623eb30b28 100644 --- a/packages/client-generator/src/generators/typescript/render-client.ts +++ b/packages/client-generator/src/generators/typescript/render-client.ts @@ -1,24 +1,26 @@ -import type { DateType } from '../../authoring/options.js'; -// The operation-level renderers behind the client assembly: the `Ops` type map, -// the `*` alias cluster, the flat call sugar, and the split layout's schema -// import list — all derived from the IR and the shared `EmitContext`. import { allOperations, type ApiModel, + type ArgsStyle, + type DateType, + type ErrorMode, + type ModelPagination, type NamedSchemaModel, type OperationModel, type ParamModel, type RequestBodyModel, type ResponseBodyModel, type SchemaModel, -} from '../../intermediate-representation/model.js'; -import type { ModelPagination } from '../../pagination.js'; -import { pascalCase, safeIdent } from '../../printers/typescript.js'; -import type { ArgsStyle, ErrorMode } from '../types.js'; -import { operationSignature, templatePathParams } from './operation-signature.js'; -import { isTypedMultipart } from './operation-types.js'; -import { responseHeadersTypeText } from './response-headers.js'; -import { tsJsdoc, tsType } from './ts-type.js'; +} from '@redocly/client-generator'; +// The operation-level renderers behind the client assembly: the `Ops` type map, +// the `*` alias cluster, the flat call sugar, and the split layout's schema +// import list — all derived from the IR and the shared `EmitContext`. +import { pascalCase, safeIdent } from '@redocly/client-generator/printers/typescript'; + +import { operationSignature, templatePathParams } from './operation-signature.ts'; +import { isTypedMultipart } from './operation-types.ts'; +import { responseHeadersTypeText } from './response-headers.ts'; +import { tsJsdoc, tsType } from './ts-type.ts'; /** * The emit configuration every operation shares. Bundling it into one value keeps diff --git a/packages/client-generator/src/generators/typescript/response-headers.ts b/packages/client-generator/src/generators/typescript/response-headers.ts index 21450c41a9..bd38728eed 100644 --- a/packages/client-generator/src/generators/typescript/response-headers.ts +++ b/packages/client-generator/src/generators/typescript/response-headers.ts @@ -1,14 +1,14 @@ // Success-response header helpers: descriptor parse hints + Ops / alias type text // for throw-mode `{ envelope: true }`. -import { headerCoerceType } from '../../authoring/index.js'; -import type { - NamedSchemaModel, - ResponseHeaderModel, - SchemaModel, -} from '../../intermediate-representation/model.js'; -import { headerPropertyKey, uniqueIdent } from '../../printers/typescript.js'; -import type { ResponseHeaderSpec } from './runtime/types.js'; +import { + headerCoerceType, + type NamedSchemaModel, + type ResponseHeaderModel, + type ResponseHeaderSpec, + type SchemaModel, +} from '@redocly/client-generator'; +import { headerPropertyKey, uniqueIdent } from '@redocly/client-generator/printers/typescript'; const INDENT = ' '; diff --git a/packages/client-generator/src/generators/typescript/runtime/types.ts b/packages/client-generator/src/generators/typescript/runtime/types.ts index 4965eb9823..b7345a0a3b 100644 --- a/packages/client-generator/src/generators/typescript/runtime/types.ts +++ b/packages/client-generator/src/generators/typescript/runtime/types.ts @@ -9,6 +9,7 @@ import type { PaginationSpec } from '../../../pagination.js'; import type { ApiErrorLike, + ResponseHeaderSpec, Middleware, OperationContext, RequestContext, @@ -63,12 +64,7 @@ export type OperationDescriptor = { responseHeaders?: readonly ResponseHeaderSpec[]; }; -/** One declared response header the runtime coerces into the envelope `headers` object. */ -export type ResponseHeaderSpec = { - name: string; - key: string; - type: 'string' | 'number' | 'boolean'; -}; +export type { ResponseHeaderSpec } from '../../../runtime-contract.js'; /** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */ export type QueryValue = diff --git a/packages/client-generator/src/generators/typescript/ts-type.ts b/packages/client-generator/src/generators/typescript/ts-type.ts index 3512815ba3..7770c872e9 100644 --- a/packages/client-generator/src/generators/typescript/ts-type.ts +++ b/packages/client-generator/src/generators/typescript/ts-type.ts @@ -2,15 +2,20 @@ // `typescript` import. Formatting contract: 4-space indent, double-quoted // literals, compound members parenthesized inside unions/intersections/arrays. -import type { DateType } from '../../authoring/options.js'; -import type { - NamedSchemaModel, - PropertyModel, - ScalarKind, - SchemaMetadata, - SchemaModel, -} from '../../intermediate-representation/model.js'; -import { escapeJsDoc, isIdentifier, jsdocText, safeIdent } from '../../printers/typescript.js'; +import { + type DateType, + type NamedSchemaModel, + type PropertyModel, + type ScalarKind, + type SchemaMetadata, + type SchemaModel, +} from '@redocly/client-generator'; +import { + escapeJsDoc, + isIdentifier, + jsdocText, + safeIdent, +} from '@redocly/client-generator/printers/typescript'; const INDENT = ' '; diff --git a/packages/client-generator/src/generators/typescript/type-guards.ts b/packages/client-generator/src/generators/typescript/type-guards.ts index 7dbd72d6e5..7e677899b9 100644 --- a/packages/client-generator/src/generators/typescript/type-guards.ts +++ b/packages/client-generator/src/generators/typescript/type-guards.ts @@ -1,8 +1,4 @@ -import type { - DiscriminatorModel, - NamedSchemaModel, - SchemaModel, -} from '../../intermediate-representation/model.js'; +import type { DiscriminatorModel, NamedSchemaModel, SchemaModel } from '@redocly/client-generator'; /** * A discriminated union we can emit guards for, found while walking the schema diff --git a/packages/client-generator/src/generators/zod/__tests__/schemas.test.ts b/packages/client-generator/src/generators/zod/__tests__/schemas.test.ts index 9b599a3a90..3908015fbb 100644 --- a/packages/client-generator/src/generators/zod/__tests__/schemas.test.ts +++ b/packages/client-generator/src/generators/zod/__tests__/schemas.test.ts @@ -1,4 +1,4 @@ -import { apiModel, operation, response } from '../../../emitters/__tests__/fixtures.js'; +import { apiModel, operation, response } from '../../../__tests__/fixtures.js'; import type { NamedSchemaModel, SchemaModel } from '../../../intermediate-representation/model.js'; import { renderZodModule, schemaToZodExpression } from '../schemas.js'; diff --git a/packages/client-generator/src/generators/zod/index.ts b/packages/client-generator/src/generators/zod/index.ts index ffd97ee299..a25fc39500 100644 --- a/packages/client-generator/src/generators/zod/index.ts +++ b/packages/client-generator/src/generators/zod/index.ts @@ -1,7 +1,7 @@ +import type { Generator } from '@redocly/client-generator'; import { join } from 'node:path'; -import type { Generator } from '../types.js'; -import { renderZodModule } from './schemas.js'; +import { renderZodModule } from './schemas.ts'; /** * The zod generator: a standalone `.zod.ts` module of Zod schemas (one diff --git a/packages/client-generator/src/generators/zod/schemas.ts b/packages/client-generator/src/generators/zod/schemas.ts index 2230e095b1..3f8a5563cb 100644 --- a/packages/client-generator/src/generators/zod/schemas.ts +++ b/packages/client-generator/src/generators/zod/schemas.ts @@ -16,8 +16,8 @@ import { type ScalarKind, type SchemaMetadata, type SchemaModel, -} from '../../intermediate-representation/model.js'; -import { codeLiteral, pascalCase, safeIdent } from '../../printers/typescript.js'; +} from '@redocly/client-generator'; +import { codeLiteral, pascalCase, safeIdent } from '@redocly/client-generator/printers/typescript'; const INDENT = ' '; diff --git a/packages/client-generator/src/index.ts b/packages/client-generator/src/index.ts index 9dede60ff3..d20a72b8d3 100644 --- a/packages/client-generator/src/index.ts +++ b/packages/client-generator/src/index.ts @@ -18,18 +18,28 @@ export type { RetryContext, RetryStrategy, } from './runtime-contract.js'; -// The generated-CLI command shapes — authoring types for wrappers around a generated -// or composed CLI (a custom `login` command); the engine itself (`runCli`) is embedded -// in, and re-exported by, every generated cli module. +// Descriptor wire shapes the generators emit and every runtime implements. +export type { ResponseHeaderSpec } from './runtime-contract.js'; +export type { ModelPagination, PaginationSpec } from './pagination.js'; +export { resolveSchemaPointer } from './pagination.js'; +// Names the generated sdk wiring reserves — the typescript descriptor keeps schema +// identifiers clear of them. +export { WIRING_NAMES } from './reserved-names.js'; +// The generated-CLI authoring contract — the command/wiring shapes a wrapper around a +// generated or composed CLI is written against, plus the two casing helpers CLI-flavored +// generators share; the engine itself (`runCli`) is embedded in, and re-exported by, +// every generated cli module. +export { constantCase, groupSlug } from './cli-contract.js'; export type { CliAuthScheme, CliCommand, + CliFlag, CliGlobals, CliWiring, CommandContext, CommandSource, CustomCommand, -} from './generators/cli/runtime/cli.js'; +} from './cli-contract.js'; // The user-facing pagination rule shapes (`Config.pagination` / `x-redoclyPagination`). export type { PaginationConfig, PaginationRule, PaginationStyle } from './pagination.js'; export type { diff --git a/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts b/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts index 33762e4c86..53a9af627d 100644 --- a/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts +++ b/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts @@ -1,7 +1,7 @@ import { logger } from '@redocly/openapi-core'; -import { reservedModuleNames } from '../emitters/reserved-names.js'; import { isSafeIdentifier, pascalCase, sanitizeIdentifier } from '../printers/typescript.js'; +import { reservedModuleNames } from '../reserved-names.js'; import type { ApiModel, OperationModel, SchemaModel } from './model.js'; /** diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index 9b7b29438f..694a35f290 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -229,7 +229,7 @@ export async function generateClient( // Baking parses TypeScript, so the module loads only when setup is actually used. let setupBlock: string | undefined; if (options.setup) { - const { bakeSetup } = await import('./emitters/setup-bake.js'); + const { bakeSetup } = await import('./setup-bake.js'); // A relative setup path resolves against `configDir` (cwd when absent), like // generator specifiers. The CLI pre-resolves its inputs, so they arrive absolute. const setupPath = resolve(options.configDir ?? process.cwd(), options.setup); diff --git a/packages/client-generator/src/plugin.ts b/packages/client-generator/src/plugin.ts index 517a982576..d27c4311e2 100644 --- a/packages/client-generator/src/plugin.ts +++ b/packages/client-generator/src/plugin.ts @@ -59,17 +59,21 @@ export type { } from './generators/types.js'; // --- The intermediate representation (the `model` a generator walks) --------------------------- +export { allOperations } from './intermediate-representation/model.js'; export type { ApiModel, + DiscriminatorModel, NamedSchemaModel, OperationModel, ParamModel, PropertyModel, RequestBodyModel, ResponseBodyModel, + ResponseHeaderModel, ScalarKind, SchemaMetadata, SchemaModel, + SecuritySchemeModel, ServerModel, ServiceModel, SseModel, diff --git a/packages/client-generator/src/emitters/reserved-names.ts b/packages/client-generator/src/reserved-names.ts similarity index 98% rename from packages/client-generator/src/emitters/reserved-names.ts rename to packages/client-generator/src/reserved-names.ts index 7fa5049d5f..5f7e08be01 100644 --- a/packages/client-generator/src/emitters/reserved-names.ts +++ b/packages/client-generator/src/reserved-names.ts @@ -8,7 +8,7 @@ // the runtime sources at prepare time (`RUNTIME_DECLARED_NAMES`), so it tracks the // real runtime with no hand-maintained list to drift. -import { RUNTIME_DECLARED_NAMES } from './runtime-sources.js'; +import { RUNTIME_DECLARED_NAMES } from './runtime-sources/typescript.js'; /** Module-scope identifiers every package-mode sdk file emits or imports — never renamed. */ export const WIRING_NAMES = [ diff --git a/packages/client-generator/src/runtime-contract.ts b/packages/client-generator/src/runtime-contract.ts index cc724cd851..11f841ebdb 100644 --- a/packages/client-generator/src/runtime-contract.ts +++ b/packages/client-generator/src/runtime-contract.ts @@ -103,3 +103,10 @@ export type ClientSetup = { config?: ClientSetupConfig; middleware?: Middleware[ export function defineClientSetup(setup: ClientSetup): ClientSetup { return setup; } + +/** One declared response header the runtime coerces into the envelope `headers` object. */ +export type ResponseHeaderSpec = { + name: string; + key: string; + type: 'string' | 'number' | 'boolean'; +}; diff --git a/packages/client-generator/src/runtime-sources.ts b/packages/client-generator/src/runtime-sources.ts index 4192e8391e..3e0fce9ea7 100644 --- a/packages/client-generator/src/runtime-sources.ts +++ b/packages/client-generator/src/runtime-sources.ts @@ -1,12 +1,14 @@ // The public `@redocly/client-generator/runtime-sources` entry: the embedded-runtime -// source strings for the language generators. Ejected generator files import these +// source strings for every generator that embeds one. 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 { GO_RUNTIME_SOURCE } from './runtime-sources/go.js'; +export { PHP_RUNTIME_SOURCE } from './runtime-sources/php.js'; +export { PYTHON_RUNTIME_SOURCES, type PythonRuntimeModuleName } from './runtime-sources/python.js'; export { - PYTHON_RUNTIME_SOURCES, - type PythonRuntimeModuleName, -} from './emitters/python-runtime-sources.js'; + RUNTIME_SOURCES, + RUNTIME_SOURCES_STRIPPED, + type RuntimeModuleName, +} from './runtime-sources/typescript.js'; diff --git a/packages/client-generator/src/emitters/__tests__/runtime-sources.test.ts b/packages/client-generator/src/runtime-sources/__tests__/typescript.test.ts similarity index 73% rename from packages/client-generator/src/emitters/__tests__/runtime-sources.test.ts rename to packages/client-generator/src/runtime-sources/__tests__/typescript.test.ts index ea6cf6ec5c..69ee98c038 100644 --- a/packages/client-generator/src/emitters/__tests__/runtime-sources.test.ts +++ b/packages/client-generator/src/runtime-sources/__tests__/typescript.test.ts @@ -2,12 +2,12 @@ import { readdirSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { RUNTIME_SOURCES } from '../runtime-sources.js'; +import { RUNTIME_SOURCES } from '../typescript.js'; const pkgSrc = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); const runtimeDir = join(pkgSrc, 'generators', 'typescript', 'runtime'); const STALE = - 'emitters/runtime-sources.ts is stale — run `npm run prepare -w @redocly/client-generator`'; + 'runtime-sources/typescript.ts is stale — run `npm run prepare -w @redocly/client-generator`'; /** A source region between two anchors (end exclusive), for the spliced-contract checks. */ function between(source: string, from: string, to: string): string { @@ -20,21 +20,25 @@ function between(source: string, from: string, to: string): string { describe('runtime-sources', () => { it('the generated snapshot matches the runtime sources (every module except the barrel)', () => { - // `types.ts` is spliced (see the splice test below) and `cli.ts` lives with the cli - // generator; every other module is embedded byte-for-byte. + // `types.ts` and `cli.ts` are spliced (see the splice tests below); every other + // module is embedded byte-for-byte. const expected = Object.fromEntries( readdirSync(runtimeDir) .filter((name) => name.endsWith('.ts') && name !== 'index.ts' && name !== 'types.ts') .map((name) => [name, readFileSync(join(runtimeDir, name), 'utf-8')]) ); - expected['cli.ts'] = readFileSync( - join(pkgSrc, 'generators', 'cli', 'runtime', 'cli.ts'), - 'utf-8' - ); - const { 'types.ts': _types, ...rest } = RUNTIME_SOURCES; + const { 'types.ts': _types, 'cli.ts': _cli, ...rest } = RUNTIME_SOURCES; expect({ ...rest }, STALE).toEqual(expected); }); + it('the embedded cli.ts splices the cli contract back in', () => { + const embedded: string = RUNTIME_SOURCES['cli.ts']; + expect(embedded).not.toContain("from '../../../cli-contract.js'"); + const contract = readFileSync(join(pkgSrc, 'cli-contract.ts'), 'utf-8'); + expect(embedded, STALE).toContain(between(contract, 'export type CliCommand =', ';')); + expect(embedded, STALE).toContain(between(contract, 'export function groupSlug', '}')); + }); + it('the embedded types.ts splices the package-level contract types back in', () => { const embedded: string = RUNTIME_SOURCES['types.ts']; // Self-contained: no import or re-export may survive into the embeddable source. diff --git a/packages/client-generator/src/emitters/go-runtime-sources.ts b/packages/client-generator/src/runtime-sources/go.ts similarity index 100% rename from packages/client-generator/src/emitters/go-runtime-sources.ts rename to packages/client-generator/src/runtime-sources/go.ts diff --git a/packages/client-generator/src/emitters/php-runtime-sources.ts b/packages/client-generator/src/runtime-sources/php.ts similarity index 100% rename from packages/client-generator/src/emitters/php-runtime-sources.ts rename to packages/client-generator/src/runtime-sources/php.ts diff --git a/packages/client-generator/src/emitters/python-runtime-sources.ts b/packages/client-generator/src/runtime-sources/python.ts similarity index 100% rename from packages/client-generator/src/emitters/python-runtime-sources.ts rename to packages/client-generator/src/runtime-sources/python.ts diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/runtime-sources/typescript.ts similarity index 100% rename from packages/client-generator/src/emitters/runtime-sources.ts rename to packages/client-generator/src/runtime-sources/typescript.ts diff --git a/packages/client-generator/src/emitters/setup-bake.ts b/packages/client-generator/src/setup-bake.ts similarity index 98% rename from packages/client-generator/src/emitters/setup-bake.ts rename to packages/client-generator/src/setup-bake.ts index a3379a3206..efc897465a 100644 --- a/packages/client-generator/src/emitters/setup-bake.ts +++ b/packages/client-generator/src/setup-bake.ts @@ -1,6 +1,6 @@ import ts from 'typescript'; -import { NotSupportedError } from '../errors.js'; +import { NotSupportedError } from './errors.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 diff --git a/tests/e2e/generate-client/eject.test.ts b/tests/e2e/generate-client/eject.test.ts index bf12f39639..38612cbe3e 100644 --- a/tests/e2e/generate-client/eject.test.ts +++ b/tests/e2e/generate-client/eject.test.ts @@ -180,7 +180,7 @@ describe('eject-generator (end-to-end)', () => { ); }, 60_000); - it('THE headline holds for a bundled TypeScript generator too', () => { + it('THE headline holds for a TypeScript-family generator too', () => { const eject = run(project, ['eject-generator', 'zod']); expect(eject.status, eject.stderr).toBe(0); @@ -203,7 +203,7 @@ describe('eject-generator (end-to-end)', () => { '--generator', 'typescript', '--generator', - './generators/zod.mjs', + './generators/zod/index.ts', ]); expect(ejected.status, ejected.stderr).toBe(0); expect(readFileSync(join(project, 'zod-ejected/client.zod.ts'), 'utf-8')).toBe( @@ -215,7 +215,7 @@ describe('eject-generator (end-to-end)', () => { 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(existsSync(join(project, 'generators/tanstack-query-vue'))).toBe(false); expect(run(project, ['eject-generator', 'nowhere']).status).not.toBe(0); }, 60_000); @@ -236,24 +236,26 @@ describe('eject-generator (end-to-end)', () => { expect(readFileSync(skillPath, 'utf-8')).toContain('We keep the legacy auth header.'); }, 60_000); - it('--update marks real conflicts, and a legacy .pristine base still works', () => { - // zod is a single-file eject, where the `.pristine/` copy from an older CLI still - // works as the merge base — and the report says it can go. - const legacy = join(project, 'generators/.pristine'); - mkdirSync(legacy, { recursive: true }); - const ejected = join(project, 'generators/zod.mjs'); - const base = readFileSync(ejected, 'utf-8').split('\n'); + it('--update marks real conflicts, and a legacy single-file eject says to re-eject', () => { + const indexFile = join(project, 'generators/zod/index.ts'); + const base = readFileSync(indexFile, 'utf-8').split('\n'); const mine = [...base]; - base[0] = '// OLD base line'; - mine[0] = '// USER edited line'; - writeFileSync(join(legacy, 'zod.mjs'), base.join('\n'), 'utf-8'); - writeFileSync(ejected, mine.join('\n'), 'utf-8'); - const conflicted = run(project, ['eject-generator', 'zod', '--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('<<<<<<<'); + mine[1] = '// USER edited line where the update also changes'; + writeFileSync(indexFile, mine.join('\n'), 'utf-8'); + // Simulate the shipped file changing on the SAME line the user edited: point the + // user's copy at the current version but alter the line, then update — the header + // version equals the toolkit version, so the asset itself is the base and the edit + // survives cleanly; a conflict needs a diverged base, covered by the unit tests. + const clean = run(project, ['eject-generator', 'zod', '--update']); + expect(clean.status, clean.stderr).toBe(0); + expect(readFileSync(indexFile, 'utf-8')).toContain('// USER edited line'); + + // A single-file copy from an older CLI cannot merge into the folder shape — the + // command says to eject fresh instead of guessing. + writeFileSync(join(project, 'generators/mock.mjs'), '// legacy single-file eject\n', 'utf-8'); + const legacy = run(project, ['eject-generator', 'mock', '--update']); + expect(legacy.status).not.toBe(0); + expect(legacy.stderr + legacy.stdout).toContain('--force'); }, 60_000); }); @@ -271,10 +273,7 @@ describe('eject-generator from source (no bundle)', () => { { cwd: project, encoding: 'utf-8' } ); expect(result.status, `${generator}: ${result.stdout}\n${result.stderr}`).toBe(0); - const copy = ['python', 'go', 'php'].includes(generator) - ? `generators/${generator}/index.ts` - : `generators/${generator}.mjs`; - expect(existsSync(join(project, copy))).toBe(true); + expect(existsSync(join(project, `generators/${generator}/index.ts`))).toBe(true); } } finally { rmSync(project, { recursive: true, force: true }); From 8ce33149a31dca9b9a4dada988a1b2bac5e00807 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 23 Aug 2026 00:54:22 +0300 Subject: [PATCH 33/35] test: the composed CLI wires the ejected folder entry, not the retired single-file path --- tests/e2e/generate-client/cli-compose.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/generate-client/cli-compose.test.ts b/tests/e2e/generate-client/cli-compose.test.ts index 6af0fd89f4..0bcbdb34e8 100644 --- a/tests/e2e/generate-client/cli-compose.test.ts +++ b/tests/e2e/generate-client/cli-compose.test.ts @@ -168,7 +168,7 @@ describe('config-driven composition (client.cliOutput)', () => { '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. + // `./generators/cli/index.ts` path entry must compose exactly like the built-in name. const ejected = spawnSync( 'node', [cliEntry, 'eject-generator', 'cli', '--config', join(project, 'redocly.yaml')], @@ -176,7 +176,7 @@ describe('config-driven composition (client.cliOutput)', () => { ); expect(ejected.status, ejected.stderr).toBe(0); expect(readFileSync(join(project, 'redocly.yaml'), 'utf-8')).toContain( - 'generators: [typescript, zod, ./generators/cli.mjs]' + 'generators: [typescript, zod, ./generators/cli/index.ts]' ); const generated = spawnSync( 'node', From bf284d29057cfadce1300133ddfe9aadeab3ecab Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 23 Aug 2026 10:17:33 +0300 Subject: [PATCH 34/35] test: the config schema snapshot follows the runtime enum change (package -> module) --- .../core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ab70606687..10836901d6 100644 --- a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap @@ -285,7 +285,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "runtime": { "enum": [ "inline", - "package", + "module", ], }, "serverUrl": { From 8d994d2e89dbd93254adc52acf82f685952e8a4e Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 23 Aug 2026 10:29:44 +0300 Subject: [PATCH 35/35] test: cover the reference-page renderer at the unit level --- .../__tests__/reference-page.test.ts | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 packages/client-generator/src/authoring/__tests__/reference-page.test.ts diff --git a/packages/client-generator/src/authoring/__tests__/reference-page.test.ts b/packages/client-generator/src/authoring/__tests__/reference-page.test.ts new file mode 100644 index 0000000000..d61f478bc6 --- /dev/null +++ b/packages/client-generator/src/authoring/__tests__/reference-page.test.ts @@ -0,0 +1,166 @@ +import { apiModel, operation, param } from '../../__tests__/fixtures.js'; +import { renderReferencePage } from '../reference-page.js'; + +const LANGUAGE = { + name: 'python', + label: 'Python', + fence: 'python', + requires: 'Requires Python >= 3.9 and httpx.', +}; + +describe('renderReferencePage', () => { + it('renders the whole page: front matter, auth table, tag groups, and per-operation facts', () => { + const model = apiModel({ + services: [ + { + name: 'Orders', + operations: [ + operation({ + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + summary: 'List | orders.', + tags: ['Orders'], + queryParams: [ + { + name: 'cursor', + in: 'query', + required: false, + schema: { kind: 'scalar', scalar: 'string' }, + description: 'Page\ncursor.', + }, + param('limit', 'query', true, { kind: 'scalar', scalar: 'integer' }), + ], + successResponses: [ + { + status: 200, + contentType: 'application/json', + schema: { kind: 'ref', name: 'OrderPage' }, + }, + ], + }), + operation({ + name: 'createOrder', + method: 'post', + path: '/orders', + tags: ['Orders'], + requestBody: { + contentType: 'application/json', + required: true, + schema: { + kind: 'union', + members: [ + { kind: 'ref', name: 'Order' }, + { kind: 'enum', values: ['a', 'b', 'c', 'd', 'e', 'f', 'g'], scalar: 'string' }, + ], + }, + }, + }), + operation({ + name: 'streamEvents', + method: 'get', + path: '/events', + tags: ['Orders'], + successResponses: [ + { status: 200, contentType: 'text/event-stream', schema: { kind: 'unknown' } }, + ], + }), + operation({ + name: 'downloadReport', + method: 'get', + path: '/report', + tags: [], + successResponses: [ + { + status: 200, + contentType: 'application/octet-stream', + schema: { kind: 'unknown' }, + }, + ], + }), + ], + }, + ], + securitySchemes: [ + { key: 'BearerAuth', kind: 'bearer' }, + { key: 'KeyAuth', kind: 'apiKeyHeader', headerName: 'X-Key' }, + ], + }); + + const page = renderReferencePage(model, { + title: 'Cafe Python reference', + frontmatter: true, + language: LANGUAGE, + sample: (op) => + op.name === 'listOrders' ? { lang: 'python', source: 'client.list_orders()\n' } : undefined, + paginated: new Set(['listOrders']), + }); + + expect(page).toContain('---\ntitle: Cafe Python reference\n---'); + expect(page).toContain('Requires Python >= 3.9 and httpx.'); + // Auth table covers both scheme spellings. + expect(page).toContain('| `BearerAuth` | bearer | `Authorization: Bearer ` |'); + expect(page).toContain('| `KeyAuth` | apiKeyHeader | the `X-Key` header |'); + // Tagged group, then the untagged fallback section. + expect(page).toContain('## Orders'); + expect(page).toContain('## Operations'); + // The sample rides in the language fence; a sample-less operation gets no fence. + expect(page).toContain('```python\nclient.list_orders()\n```'); + // Summaries and descriptions are table-cell-safe: pipes escaped, newlines collapsed. + expect(page).toContain('List \\| orders.'); + expect(page).toContain('| `cursor` | query | string | no | Page cursor. |'); + expect(page).toContain('| `limit` | query | integer | yes | |'); + // Type labels: refs by name, unions joined, long enums truncated. + expect(page).toContain('Returns `application/json`, of type OrderPage.'); + expect(page).toContain('of type Order or enum: a, b, c, d, e, f, and 1 more.'); + // The three declaration-level facts. + expect(page).toContain( + 'This operation is paginated, so the SDK gives it page and item iterators.' + ); + expect(page).toContain( + 'This operation streams server-sent events, so the SDK iterates the events.' + ); + expect(page).toContain('This operation returns binary content.'); + // A bodyless, responseless operation would say "Returns no content." — createOrder has a + // body line instead. + expect(page).toContain('Body: `application/json`, required, of type Order or enum:'); + }); + + it('falls back to config-resolved pagination and the no-schemes line without a resolved set', () => { + const model = apiModel({ + services: [ + { + name: 'Default', + operations: [ + operation({ + name: 'listItems', + specName: 'listItems', + method: 'get', + path: '/items', + queryParams: [param('offset', 'query', false, { kind: 'scalar', scalar: 'integer' })], + successResponses: [ + { + status: 200, + contentType: 'application/json', + schema: { kind: 'array', items: { kind: 'scalar', scalar: 'string' } }, + }, + ], + }), + ], + }, + ], + }); + const page = renderReferencePage(model, { + title: 'Items reference', + frontmatter: false, + language: LANGUAGE, + sample: () => undefined, + pagination: { style: 'offset', offsetParam: 'offset', items: '' }, + }); + expect(page.startsWith('# Items reference')).toBe(true); + expect(page).toContain('The description declares no security schemes.'); + expect(page).toContain('This operation is paginated'); + expect(page).toContain('array of string'); + }); +});